From 28fbdce66b2ce5ca2210b57ee6496e563a95ace8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 17 Jun 2026 18:25:54 -0400 Subject: [PATCH 01/11] feat(ui): surface interactive captcha signal via onInteractiveChange Add an optional onInteractiveChange prop to CaptchaElement that fires when Cloudflare Turnstile escalates to / resolves an interactive challenge (driven off the existing #clerk-captcha maxHeight MutationObserver). Behavior-preserving: no caller passes the prop yet. Also seeds reusable test-only captcha helpers and Cloudflare test-sitekey constants, plus a turnstile.ts characterization test pinning that invisible challenges never touch #clerk-captcha. --- .../utils/captcha/__tests__/turnstile.test.ts | 64 +++++++++++++++++++ packages/ui/src/elements/CaptchaElement.tsx | 23 ++++++- .../__tests__/CaptchaElement.test.tsx | 63 ++++++++++++++++++ packages/ui/src/test/captcha.ts | 57 +++++++++++++++++ 4 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts create mode 100644 packages/ui/src/elements/__tests__/CaptchaElement.test.tsx create mode 100644 packages/ui/src/test/captcha.ts diff --git a/packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts b/packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts new file mode 100644 index 00000000000..d49164ce99d --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts @@ -0,0 +1,64 @@ +import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from '@clerk/shared/internal/clerk-js/constants'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getTurnstileToken } from '../turnstile'; + +/** + * Regression guard for the inline "spotlight": the spotlight keys off + * `#clerk-captcha`'s `style.maxHeight !== '0'`. The common invisible flow (~99% of + * challenges) must never create or mutate `#clerk-captcha`, so its `maxHeight` stays + * `'0'` and no spotlight is ever triggered. This pins that existing behavior. + */ +describe('getTurnstileToken — invisible flow', () => { + let renderConfig: Record | undefined; + + beforeEach(() => { + renderConfig = undefined; + (window as any).turnstile = { + render: vi.fn((_selector: string, config: Record) => { + renderConfig = config; + // Real Turnstile returns the widget id, then fires the callback asynchronously. + // Invisible challenges resolve without ever escalating to interactive, so the + // `before-interactive-callback` (the only code that expands #clerk-captcha) never fires. + setTimeout(() => config.callback('mock_token'), 0); + return 'mock_widget_id'; + }), + remove: vi.fn(), + reset: vi.fn(), + }; + }); + + afterEach(() => { + document.body.innerHTML = ''; + delete (window as any).turnstile; + vi.restoreAllMocks(); + }); + + it('renders into its own throwaway container, never #clerk-captcha', async () => { + // An inline #clerk-captcha may coexist on the page (e.g. a Start card is mounted), + // but an invisible challenge must never touch it. + const inline = document.createElement('div'); + inline.id = CAPTCHA_ELEMENT_ID; + inline.style.maxHeight = '0'; + document.body.appendChild(inline); + + const result = await getTurnstileToken({ + captchaProvider: 'turnstile', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'invisible', + }); + + expect(result).toEqual({ captchaToken: 'mock_token', captchaWidgetType: 'invisible' }); + + // The invisible flow targets its own `.clerk-invisible-captcha` div with the invisible key. + expect((window as any).turnstile.render).toHaveBeenCalledWith(`.${CAPTCHA_INVISIBLE_CLASSNAME}`, expect.anything()); + expect(renderConfig?.sitekey).toBe('invisible-key'); + + // The spotlight signal on #clerk-captcha is untouched throughout → no spotlight. + expect(inline.style.maxHeight || '0').toBe('0'); + + // The temporary invisible container is created then cleaned up in `finally`. + expect(document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`)).toBeNull(); + }); +}); diff --git a/packages/ui/src/elements/CaptchaElement.tsx b/packages/ui/src/elements/CaptchaElement.tsx index 86e2e93853b..75a12aa79d2 100644 --- a/packages/ui/src/elements/CaptchaElement.tsx +++ b/packages/ui/src/elements/CaptchaElement.tsx @@ -1,5 +1,5 @@ import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Box, useAppearance, useLocalizations } from '../customizables'; @@ -8,12 +8,23 @@ import { Box, useAppearance, useLocalizations } from '../customizables'; * 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. + * + * When Turnstile escalates to an interactive "Verify you are human" challenge it sets the element's + * `maxHeight` to a non-`'0'` value (reset back to `'0'` on resolve/error). `onInteractiveChange` surfaces + * that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount. */ -export const CaptchaElement = () => { +export const CaptchaElement = ({ onInteractiveChange }: { onInteractiveChange?: (interactive: boolean) => void }) => { const elementRef = useRef(null); const maxHeightValueRef = useRef('0'); const minHeightValueRef = useRef('unset'); const marginBottomValueRef = useRef('unset'); + // State exists to force a re-render on the interactive transition, which re-applies the ref-held + // styles above (preserving Turnstile's injected values). The value itself drives the `position` + // toggle added in the next commit. + const [, setIsInteractive] = useState(false); + // The observer is set up once (`[]` deps), so it reads the latest callback through a ref. + const onInteractiveChangeRef = useRef(onInteractiveChange); + onInteractiveChangeRef.current = onInteractiveChange; const { parsedCaptcha } = useAppearance(); const { locale } = useLocalizations(); const captchaTheme = parsedCaptcha?.theme; @@ -34,6 +45,14 @@ export const CaptchaElement = () => { maxHeightValueRef.current = target.style.maxHeight || '0'; minHeightValueRef.current = target.style.minHeight || 'unset'; marginBottomValueRef.current = target.style.marginBottom || 'unset'; + + // ORDERING IS LOAD-BEARING: derive and apply the interactive signal only AFTER the refs above + // are updated. The re-render that `setIsInteractive` triggers reads those refs fresh; setting + // state earlier (or from a separate effect) would write stale defaults back into `style` and + // clobber Turnstile's injected styles. + const nowInteractive = maxHeightValueRef.current !== '0'; + setIsInteractive(nowInteractive); + onInteractiveChangeRef.current?.(nowInteractive); } }); }); diff --git a/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx b/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx new file mode 100644 index 00000000000..a56220fc424 --- /dev/null +++ b/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx @@ -0,0 +1,63 @@ +import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; +import { describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { simulateCaptchaInteractive, simulateCaptchaResolved } from '@/test/captcha'; +import { render, waitFor } from '@/test/utils'; + +import { CaptchaElement } from '../CaptchaElement'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +const getCaptcha = () => document.getElementById(CAPTCHA_ELEMENT_ID) as HTMLElement; + +describe('CaptchaElement', () => { + it('renders the captcha element collapsed by default', async () => { + const { wrapper } = await createFixtures(); + render(, { wrapper }); + + const el = getCaptcha(); + expect(el).not.toBeNull(); + expect(el.style.maxHeight || '0').toBe('0'); + }); + + it('does not call onInteractiveChange on mount', async () => { + const { wrapper } = await createFixtures(); + const onInteractiveChange = vi.fn(); + render(, { wrapper }); + + // Give the observer a tick to (incorrectly) fire if it were going to. + await new Promise(resolve => setTimeout(resolve, 0)); + expect(onInteractiveChange).not.toHaveBeenCalled(); + }); + + it('calls onInteractiveChange(true) when the challenge becomes interactive', async () => { + const { wrapper } = await createFixtures(); + const onInteractiveChange = vi.fn(); + render(, { wrapper }); + + simulateCaptchaInteractive(getCaptcha()); + + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(true)); + }); + + it('calls onInteractiveChange(false) when the challenge resolves', async () => { + const { wrapper } = await createFixtures(); + const onInteractiveChange = vi.fn(); + render(, { wrapper }); + + simulateCaptchaInteractive(getCaptcha()); + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(true)); + + simulateCaptchaResolved(getCaptcha()); + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(false)); + }); + + it('does not throw when onInteractiveChange is omitted', async () => { + const { wrapper } = await createFixtures(); + render(, { wrapper }); + + expect(() => simulateCaptchaInteractive(getCaptcha())).not.toThrow(); + await waitFor(() => expect((getCaptcha().style.maxHeight || '0') !== '0').toBe(true)); + }); +}); diff --git a/packages/ui/src/test/captcha.ts b/packages/ui/src/test/captcha.ts new file mode 100644 index 00000000000..06a3d9e4b48 --- /dev/null +++ b/packages/ui/src/test/captcha.ts @@ -0,0 +1,57 @@ +/** + * Test-only helpers for driving Cloudflare Turnstile captcha flows. + * + * Not bundled: this module lives under `src/test/` (the `@/test/*` alias resolves + * only during test runs) and is never exported from the package entry. + * + * The interactive signal: our Turnstile logic (`turnstile.ts`) mutates the inline + * `#clerk-captcha` element's `style.maxHeight` outside the React lifecycle — + * `'unset'` (or any non-`'0'` value) while an interactive "Verify you are human" + * challenge is showing, back to `'0'` once it resolves. `CaptchaElement` observes + * this via a MutationObserver. These helpers reproduce that transition so tests + * don't hand-roll the fiddly style mutation inline. + */ + +/** Simulate Turnstile escalating to an interactive challenge (widget expands). */ +export const simulateCaptchaInteractive = (el: HTMLElement) => { + el.style.maxHeight = 'unset'; +}; + +/** Simulate the interactive challenge resolving (widget collapses back). */ +export const simulateCaptchaResolved = (el: HTMLElement) => { + el.style.maxHeight = '0'; +}; + +/** + * Cloudflare's documented dummy sitekeys for testing Turnstile. + * @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/ + * + * Force the interactive path locally by pointing the instance at + * `FORCES_INTERACTIVE` and disabling the OAuth captcha bypass — LOCAL ONLY, + * never commit these into instance config. + */ +export const TEST_SITEKEYS = { + /** Always passes, visible widget. */ + ALWAYS_PASSES_VISIBLE: '1x00000000000000000000AA', + /** Always fails, visible widget. */ + ALWAYS_FAILS_VISIBLE: '2x00000000000000000000AB', + /** Always passes, invisible widget. */ + ALWAYS_PASSES_INVISIBLE: '1x00000000000000000000BB', + /** Always fails, invisible widget. */ + ALWAYS_FAILS_INVISIBLE: '2x00000000000000000000BB', + /** Forces an interactive challenge, visible widget. */ + FORCES_INTERACTIVE: '3x00000000000000000000FF', +} as const; + +/** + * Cloudflare's documented dummy secret keys (server-side verification). + * @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/ + */ +export const TEST_SECRET_KEYS = { + /** Always passes validation. */ + ALWAYS_PASSES: '1x0000000000000000000000000000000AA', + /** Always fails validation. */ + ALWAYS_FAILS: '2x0000000000000000000000000000000AA', + /** Returns a "token already spent" error. */ + TOKEN_ALREADY_SPENT: '3x0000000000000000000000000000000AA', +} as const; From f4498bde9467e65c3accacc26a04c824ef08c0c5 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 17 Jun 2026 18:36:33 -0400 Subject: [PATCH 02/11] refactor(ui): hoist start-flow captcha to a single slot Consolidate the scattered render sites in the SignIn/SignUp Start flows into one slot rendered as a sibling of the descriptors.main column (direct child of Card.Content), so the widget can stay mounted while sitting outside the soon-to-be-inert subtree. Add an opt-in `gapless` prop that drops the collapsed element out of flow (position:absolute) so it adds no gap gutter to the flex parent; only the start-flow slot opts in, leaving the other render sites unchanged. Behavior-preserving (no spotlight wired yet). --- .../ui/src/components/SignIn/SignInStart.tsx | 3 +- .../SignIn/__tests__/SignInStart.test.tsx | 19 ++++++++++++ .../ui/src/components/SignUp/SignUpForm.tsx | 2 -- .../ui/src/components/SignUp/SignUpStart.tsx | 2 +- .../SignUp/__tests__/SignUpStart.test.tsx | 30 ++++++++++++++++++- packages/ui/src/elements/CaptchaElement.tsx | 23 ++++++++++---- 6 files changed, 68 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 558ca7e689f..84a691107b4 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -629,13 +629,11 @@ function SignInStartInternal(): JSX.Element { /> - ) : null} - {!standardFormAttributes.length && } {userSettings.attributes.passkey?.enabled && userSettings.passkeySettings.show_sign_in_button && isWebSupported && ( @@ -647,6 +645,7 @@ function SignInStartInternal(): JSX.Element { )} + {userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && ( diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 5ea22b49e40..c61dda1ebc0 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -1,4 +1,5 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import type { SignInResource } from '@clerk/shared/types'; import { waitFor } from '@testing-library/react'; @@ -715,4 +716,22 @@ describe('SignInStart', () => { ); }); }); + + describe('Captcha', () => { + it('renders the captcha widget in the form (email) config', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + }); + render(, { wrapper }); + expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull(); + }); + + it('renders the captcha widget in the social-only (no form) config', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + render(, { wrapper }); + expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull(); + }); + }); }); diff --git a/packages/ui/src/components/SignUp/SignUpForm.tsx b/packages/ui/src/components/SignUp/SignUpForm.tsx index 0bfffecf701..e36a979e464 100644 --- a/packages/ui/src/components/SignUp/SignUpForm.tsx +++ b/packages/ui/src/components/SignUp/SignUpForm.tsx @@ -5,7 +5,6 @@ import { LegalCheckbox } from '@/ui/elements/LegalConsentCheckbox'; import type { FormControlState } from '@/ui/utils/useFormControl'; import { Col, localizationKeys, useAppearance } from '../../customizables'; -import { CaptchaElement } from '../../elements/CaptchaElement'; import { mqu } from '../../styledSystem'; import type { ActiveIdentifier, Fields } from './signUpFormHelpers'; @@ -116,7 +115,6 @@ export const SignUpForm = (props: SignUpFormProps) => { )} - )} - {!shouldShowForm && } + diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx index 2e6f6c26f92..b9ccf89ed33 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx @@ -1,7 +1,8 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import type { SignUpResource } from '@clerk/shared/types'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { fireEvent, render, screen, waitFor } from '@/test/utils'; @@ -523,4 +524,31 @@ describe('SignUpStart', () => { await waitFor(() => screen.getByText(/create your account/i)); }); }); + + describe('Captcha', () => { + // The ticket tests above mutate window.location and don't restore it (no afterEach in this + // suite); reset to a clean URL so the ticket flow doesn't fire during these renders. + beforeEach(() => { + Object.defineProperty(window, 'location', { + writable: true, + value: new URL('http://localhost/sign-up'), + }); + }); + + it('renders the captcha widget in the form (email) config', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress({ required: true }); + }); + render(, { wrapper }); + expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull(); + }); + + it('renders the captcha widget in the social-only (no form) config', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + render(, { wrapper }); + expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull(); + }); + }); }); diff --git a/packages/ui/src/elements/CaptchaElement.tsx b/packages/ui/src/elements/CaptchaElement.tsx index 75a12aa79d2..86faa841ae6 100644 --- a/packages/ui/src/elements/CaptchaElement.tsx +++ b/packages/ui/src/elements/CaptchaElement.tsx @@ -13,15 +13,25 @@ import { Box, useAppearance, useLocalizations } from '../customizables'; * `maxHeight` to a non-`'0'` value (reset back to `'0'` on resolve/error). `onInteractiveChange` surfaces * that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount. */ -export const CaptchaElement = ({ onInteractiveChange }: { onInteractiveChange?: (interactive: boolean) => void }) => { +export const CaptchaElement = ({ + onInteractiveChange, + gapless, +}: { + onInteractiveChange?: (interactive: boolean) => void; + /** + * When true, the element is removed from flow (`position:absolute`) while collapsed so it adds no + * gap gutter to a flex parent, switching to `position:static` while interactive. Opt-in so the + * other (non-spotlight) render sites keep their current positioning. + */ + gapless?: boolean; +}) => { const elementRef = useRef(null); const maxHeightValueRef = useRef('0'); const minHeightValueRef = useRef('unset'); const marginBottomValueRef = useRef('unset'); - // State exists to force a re-render on the interactive transition, which re-applies the ref-held - // styles above (preserving Turnstile's injected values). The value itself drives the `position` - // toggle added in the next commit. - const [, setIsInteractive] = useState(false); + // State forces a re-render on the interactive transition, which re-applies the ref-held styles + // above (preserving Turnstile's injected values) and drives the `gapless` position toggle. + const [isInteractive, setIsInteractive] = useState(false); // The observer is set up once (`[]` deps), so it reads the latest callback through a ref. const onInteractiveChangeRef = useRef(onInteractiveChange); onInteractiveChangeRef.current = onInteractiveChange; @@ -75,6 +85,9 @@ export const CaptchaElement = ({ onInteractiveChange }: { onInteractiveChange?: maxHeight: maxHeightValueRef.current, minHeight: minHeightValueRef.current, marginBottom: marginBottomValueRef.current, + // When `gapless`, drop out of flow while collapsed so the element contributes no gap gutter + // to its flex parent; rejoin flow once the interactive challenge expands it. + position: gapless ? (isInteractive ? 'static' : 'absolute') : undefined, }} data-cl-theme={captchaTheme} data-cl-size={captchaSize} From 4337e901e388fde8e9cc9421ee1eb1bbe001ee62 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 17 Jun 2026 18:44:41 -0400 Subject: [PATCH 03/11] feat(ui): spotlight interactive captcha during sign-in/sign-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an interactive bot-protection challenge appears, collapse and inert the descriptors.main column (social buttons, divider, form) so only the header and the captcha widget remain — bringing the 'Verify you are human' check to the foreground. The card restores once the challenge resolves. The passkey action is moved out of the main column so it stays reachable during the spotlight. No focus is moved; the captcha is a non-inert sibling reachable by keyboard. Invisible challenges (~99%) are unaffected. --- .../ui/src/components/SignIn/SignInStart.tsx | 32 ++++++++----- .../SignIn/__tests__/SignInStart.test.tsx | 46 +++++++++++++++++++ .../ui/src/components/SignUp/SignUpStart.tsx | 11 ++++- .../SignUp/__tests__/SignUpStart.test.tsx | 33 +++++++++++++ .../__tests__/CaptchaElement.test.tsx | 2 +- 5 files changed, 111 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 84a691107b4..3d83a70a162 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -158,6 +158,9 @@ function SignInStartInternal(): JSX.Element { const hasSocialOrWeb3Buttons = !!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length; const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons); + // When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the + // rest of the card (see the descriptors.main column below). + const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false); const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', { ...currentIdentifier, isRequired: true, @@ -597,6 +600,9 @@ function SignInStartInternal(): JSX.Element { {hasSocialOrWeb3Buttons && ( @@ -634,18 +640,22 @@ function SignInStartInternal(): JSX.Element { ) : null} - {userSettings.attributes.passkey?.enabled && - userSettings.passkeySettings.show_sign_in_button && - isWebSupported && ( - - authenticateWithPasskey({ flow: 'discoverable' })} - /> - - )} - + + {/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */} + {userSettings.attributes.passkey?.enabled && + userSettings.passkeySettings.show_sign_in_button && + isWebSupported && ( + + authenticateWithPasskey({ flow: 'discoverable' })} + /> + + )} {userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && ( diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index c61dda1ebc0..f6f96c6eda5 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -5,6 +5,7 @@ import type { SignInResource } from '@clerk/shared/types'; import { waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { simulateCaptchaInteractive, simulateCaptchaResolved } from '@/test/captcha'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { fireEvent, mockWebAuthn, render, screen } from '@/test/utils'; import { CardStateProvider } from '@/ui/elements/contexts'; @@ -734,4 +735,49 @@ describe('SignInStart', () => { expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull(); }); }); + + describe('Captcha spotlight', () => { + const getCaptcha = () => document.getElementById(CAPTCHA_ELEMENT_ID) as HTMLElement; + + it('inerts the form/social subtree while interactive — never the captcha or header — and restores', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withSocialProvider({ provider: 'google' }); + }); + render(, { wrapper }); + + const google = screen.getByText(/continue with google/i); + expect(google.closest('[inert]')).toBeNull(); + + simulateCaptchaInteractive(getCaptcha()); + + await waitFor(() => expect(google.closest('[inert]')).not.toBeNull()); + // The captcha widget and the header stay outside the inert subtree. + expect(getCaptcha().closest('[inert]')).toBeNull(); + expect(screen.getByRole('heading').closest('[inert]')).toBeNull(); + + simulateCaptchaResolved(getCaptcha()); + await waitFor(() => expect(google.closest('[inert]')).toBeNull()); + }); + + mockWebAuthn(() => { + it('keeps the passkey action available (outside the inert subtree) during the spotlight', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withPasskey(); + f.withPasskeySettings({ + allow_autofill: false, + show_sign_in_button: true, + }); + }); + render(, { wrapper }); + + simulateCaptchaInteractive(getCaptcha()); + // Spotlight is active (the form is inert)... + await waitFor(() => expect(screen.getByText(/email address/i).closest('[inert]')).not.toBeNull()); + // ...but the passkey action remains reachable. + expect(screen.getByText('Use passkey instead').closest('[inert]')).toBeNull(); + }); + }); + }); }); diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx index f018c63c209..28edf6dae06 100644 --- a/packages/ui/src/components/SignUp/SignUpStart.tsx +++ b/packages/ui/src/components/SignUp/SignUpStart.tsx @@ -65,6 +65,9 @@ function SignUpStartInternal(): JSX.Element { ); const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false); + // When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the + // rest of the card (see the descriptors.main column below). + const [captchaIsInteractive, setCaptchaIsInteractive] = React.useState(false); const { userSettings: { passwordSettings, usernameSettings }, @@ -442,6 +445,9 @@ function SignUpStartInternal(): JSX.Element { direction='col' elementDescriptor={descriptors.main} gap={6} + // @ts-ignore - `inert` is not yet in the installed React types + inert={captchaIsInteractive ? '' : undefined} + sx={captchaIsInteractive ? { visibility: 'hidden', height: 0, overflow: 'hidden' } : undefined} > {(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && ( @@ -465,7 +471,10 @@ function SignUpStartInternal(): JSX.Element { )} - + diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx index b9ccf89ed33..9b285405145 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx @@ -4,6 +4,7 @@ import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import type { SignUpResource } from '@clerk/shared/types'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { simulateCaptchaInteractive, simulateCaptchaResolved } from '@/test/captcha'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { fireEvent, render, screen, waitFor } from '@/test/utils'; import { CardStateProvider } from '@/ui/elements/contexts'; @@ -551,4 +552,36 @@ describe('SignUpStart', () => { expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull(); }); }); + + describe('Captcha spotlight', () => { + // The ticket tests above mutate window.location and don't restore it; reset to a clean URL. + beforeEach(() => { + Object.defineProperty(window, 'location', { + writable: true, + value: new URL('http://localhost/sign-up'), + }); + }); + + const getCaptcha = () => document.getElementById(CAPTCHA_ELEMENT_ID) as HTMLElement; + + it('inerts the form/social subtree while interactive — never the captcha or header — and restores', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress({ required: true }); + f.withSocialProvider({ provider: 'google' }); + }); + render(, { wrapper }); + + const google = screen.getByText(/continue with google/i); + expect(google.closest('[inert]')).toBeNull(); + + simulateCaptchaInteractive(getCaptcha()); + + await waitFor(() => expect(google.closest('[inert]')).not.toBeNull()); + expect(getCaptcha().closest('[inert]')).toBeNull(); + expect(screen.getByRole('heading').closest('[inert]')).toBeNull(); + + simulateCaptchaResolved(getCaptcha()); + await waitFor(() => expect(google.closest('[inert]')).toBeNull()); + }); + }); }); diff --git a/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx b/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx index a56220fc424..2305ab99f2b 100644 --- a/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx +++ b/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx @@ -1,8 +1,8 @@ import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { describe, expect, it, vi } from 'vitest'; -import { bindCreateFixtures } from '@/test/create-fixtures'; import { simulateCaptchaInteractive, simulateCaptchaResolved } from '@/test/captcha'; +import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, waitFor } from '@/test/utils'; import { CaptchaElement } from '../CaptchaElement'; From d289d93334d86459a449f563506eea019167b4ed Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 17 Jun 2026 18:48:26 -0400 Subject: [PATCH 04/11] chore(ui): add changeset for inline captcha spotlight --- .changeset/captcha-inline-spotlight.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/captcha-inline-spotlight.md diff --git a/.changeset/captcha-inline-spotlight.md b/.changeset/captcha-inline-spotlight.md new file mode 100644 index 00000000000..ef75d4751cb --- /dev/null +++ b/.changeset/captcha-inline-spotlight.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +When an interactive bot-protection challenge appears during sign-in or sign-up, the card now brings the challenge to the foreground — hiding the other fields and buttons until it's solved — so it's clear the "Verify you are human" check must be completed. Invisible challenges are unaffected. From 14994428611ec6af2c59e4c92bd9052f0202065c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 17 Jun 2026 18:54:03 -0400 Subject: [PATCH 05/11] chore(js): add sandbox `?captcha=interactive` override to demo the inline spotlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sandbox-only: when the query param is present, override the loaded environment to use Cloudflare's test sitekey (3x…FF), render inline, and disable the OAuth bypass so the interactive captcha spotlight can be exercised without changing instance settings or editing src/. No effect without the param. --- packages/clerk-js/sandbox/app.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 3c345812ff2..2e661a84de5 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -186,6 +186,31 @@ function assertClerkIsLoaded(c: ClerkType | undefined): asserts c is ClerkType { } } +/** + * Sandbox-only demo aid: force an interactive Cloudflare Turnstile challenge so the inline captcha + * "spotlight" can be exercised without changing instance settings. Enable with `?captcha=interactive`. + * + * Overrides the loaded environment (never `src/`) to use Cloudflare's documented test sitekey + * `3x00000000000000000000FF` (which always escalates to interactive), render it inline, and disable + * the OAuth bypass so the "Continue with …" path also hits the challenge. Remove the query param to + * get the normal (mostly invisible) behavior back. + */ +function applyCaptchaSandboxOverrides() { + if (new URL(window.location.href).searchParams.get('captcha') !== 'interactive') { + return; + } + const env = (Clerk as unknown as { __internal_environment?: any })?.__internal_environment; + if (!env) { + console.warn('[sandbox] captcha=interactive: environment not loaded, skipping override'); + return; + } + env.displayConfig.captchaPublicKey = '3x00000000000000000000FF'; // forces an interactive challenge + env.displayConfig.captchaWidgetType = 'smart'; // render inline into #clerk-captcha + env.displayConfig.captchaOauthBypass = []; // also exercise the OAuth ("Continue with …") path + env.userSettings.signUp.captcha_enabled = true; // ensure the widget actually runs + console.info('[sandbox] Forcing interactive captcha (test sitekey 3x…FF).'); +} + function mountIndex(element: HTMLDivElement) { assertClerkIsLoaded(Clerk); const user = Clerk.user; @@ -507,6 +532,7 @@ void (async () => { }, localization: l[initialLocale as keyof typeof l], }); + applyCaptchaSandboxOverrides(); renderCurrentRoute(); const { pane } = await initControls(); From b694ad756e04ebc0efb74320efd2a37aaa0a474e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 17 Jun 2026 19:57:22 -0400 Subject: [PATCH 06/11] fix(ui): lift captcha spotlight on resolve when browser serializes maxHeight 0 as 0px The interactive check compared maxHeight against the literal '0', but browsers serialize a reset maxHeight of '0' to '0px' when read back off style, so the collapse read as still-interactive and the spotlight never lifted (blank card after the challenge resolved). Compare the parsed length instead. The test helper now collapses with '0px' to mirror the real browser value (jsdom keeps a literal '0', which hid this bug). --- packages/ui/src/elements/CaptchaElement.tsx | 9 +++++++-- packages/ui/src/test/captcha.ts | 11 +++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/elements/CaptchaElement.tsx b/packages/ui/src/elements/CaptchaElement.tsx index 86faa841ae6..b31e0e3d63a 100644 --- a/packages/ui/src/elements/CaptchaElement.tsx +++ b/packages/ui/src/elements/CaptchaElement.tsx @@ -25,7 +25,7 @@ export const CaptchaElement = ({ */ gapless?: boolean; }) => { - const elementRef = useRef(null); + const elementRef = useRef(null); const maxHeightValueRef = useRef('0'); const minHeightValueRef = useRef('unset'); const marginBottomValueRef = useRef('unset'); @@ -60,7 +60,12 @@ export const CaptchaElement = ({ // are updated. The re-render that `setIsInteractive` triggers reads those refs fresh; setting // state earlier (or from a separate effect) would write stale defaults back into `style` and // clobber Turnstile's injected styles. - const nowInteractive = maxHeightValueRef.current !== '0'; + // + // Turnstile expands the widget by setting `maxHeight: 'unset'` and collapses it back to `'0'`. + // Browsers serialize that `'0'` to `'0px'` when read back, so compare the PARSED length to zero + // rather than the literal `'0'` — otherwise the collapse reads as a non-`'0'` string and the + // spotlight stays stuck (blank card) after the challenge resolves. + const nowInteractive = parseFloat(maxHeightValueRef.current) > 0 || maxHeightValueRef.current === 'unset'; setIsInteractive(nowInteractive); onInteractiveChangeRef.current?.(nowInteractive); } diff --git a/packages/ui/src/test/captcha.ts b/packages/ui/src/test/captcha.ts index 06a3d9e4b48..2f491b3bea4 100644 --- a/packages/ui/src/test/captcha.ts +++ b/packages/ui/src/test/captcha.ts @@ -17,9 +17,16 @@ export const simulateCaptchaInteractive = (el: HTMLElement) => { el.style.maxHeight = 'unset'; }; -/** Simulate the interactive challenge resolving (widget collapses back). */ +/** + * Simulate the interactive challenge resolving (widget collapses back). + * + * `turnstile.ts` resets `maxHeight` to `'0'`, but browsers serialize that to `'0px'` + * when it's read back off `style` — so collapse with `'0px'` to mirror what the + * MutationObserver actually sees in a real browser (jsdom would keep a literal `'0'` + * and hide the off-by-serialization bug this guards against). + */ export const simulateCaptchaResolved = (el: HTMLElement) => { - el.style.maxHeight = '0'; + el.style.maxHeight = '0px'; }; /** From fb7c8675d89a3ca06e66528f30e27890cf0fd203 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 17 Jun 2026 19:57:27 -0400 Subject: [PATCH 07/11] fix(ui): collapse spotlighted column with display:none to drop the flex gap The collapsed start-flow column used visibility:hidden;height:0, which keeps it a flex child of Card.Content and so still contributes a gap gutter, leaving a large empty space above the spotlighted captcha. display:none removes it from flow entirely; the subtree stays mounted so form state is preserved. --- packages/ui/src/components/SignIn/SignInStart.tsx | 6 +++++- packages/ui/src/components/SignUp/SignUpStart.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 3d83a70a162..76d873fa8db 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -602,7 +602,11 @@ function SignInStartInternal(): JSX.Element { gap={6} // @ts-ignore - `inert` is not yet in the installed React types inert={captchaIsInteractive ? '' : undefined} - sx={captchaIsInteractive ? { visibility: 'hidden', height: 0, overflow: 'hidden' } : undefined} + // `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and + // contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above + // the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then + // redundant-but-harmless. + sx={captchaIsInteractive ? { display: 'none' } : undefined} > {hasSocialOrWeb3Buttons && ( diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx index 28edf6dae06..2ff305f03da 100644 --- a/packages/ui/src/components/SignUp/SignUpStart.tsx +++ b/packages/ui/src/components/SignUp/SignUpStart.tsx @@ -447,7 +447,11 @@ function SignUpStartInternal(): JSX.Element { gap={6} // @ts-ignore - `inert` is not yet in the installed React types inert={captchaIsInteractive ? '' : undefined} - sx={captchaIsInteractive ? { visibility: 'hidden', height: 0, overflow: 'hidden' } : undefined} + // `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and + // contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above + // the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then + // redundant-but-harmless. + sx={captchaIsInteractive ? { display: 'none' } : undefined} > {(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && ( From e9df95395e7fdfcbd9fd58af3c0d256aaee1b490 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 17 Jun 2026 21:15:50 -0400 Subject: [PATCH 08/11] refactor(ui): dedup SignUpStart captcha beforeEach and skip redundant MutationObserver callbacks --- .../SignUp/__tests__/SignUpStart.test.tsx | 42 ++++++++----------- packages/ui/src/elements/CaptchaElement.tsx | 8 +++- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx index 9b285405145..a4984f49263 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx @@ -551,37 +551,29 @@ describe('SignUpStart', () => { render(, { wrapper }); expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull(); }); - }); - - describe('Captcha spotlight', () => { - // The ticket tests above mutate window.location and don't restore it; reset to a clean URL. - beforeEach(() => { - Object.defineProperty(window, 'location', { - writable: true, - value: new URL('http://localhost/sign-up'), - }); - }); - const getCaptcha = () => document.getElementById(CAPTCHA_ELEMENT_ID) as HTMLElement; + describe('spotlight', () => { + const getCaptcha = () => document.getElementById(CAPTCHA_ELEMENT_ID) as HTMLElement; - it('inerts the form/social subtree while interactive — never the captcha or header — and restores', async () => { - const { wrapper } = await createFixtures(f => { - f.withEmailAddress({ required: true }); - f.withSocialProvider({ provider: 'google' }); - }); - render(, { wrapper }); + it('inerts the form/social subtree while interactive — never the captcha or header — and restores', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress({ required: true }); + f.withSocialProvider({ provider: 'google' }); + }); + render(, { wrapper }); - const google = screen.getByText(/continue with google/i); - expect(google.closest('[inert]')).toBeNull(); + const google = screen.getByText(/continue with google/i); + expect(google.closest('[inert]')).toBeNull(); - simulateCaptchaInteractive(getCaptcha()); + simulateCaptchaInteractive(getCaptcha()); - await waitFor(() => expect(google.closest('[inert]')).not.toBeNull()); - expect(getCaptcha().closest('[inert]')).toBeNull(); - expect(screen.getByRole('heading').closest('[inert]')).toBeNull(); + await waitFor(() => expect(google.closest('[inert]')).not.toBeNull()); + expect(getCaptcha().closest('[inert]')).toBeNull(); + expect(screen.getByRole('heading').closest('[inert]')).toBeNull(); - simulateCaptchaResolved(getCaptcha()); - await waitFor(() => expect(google.closest('[inert]')).toBeNull()); + simulateCaptchaResolved(getCaptcha()); + await waitFor(() => expect(google.closest('[inert]')).toBeNull()); + }); }); }); }); diff --git a/packages/ui/src/elements/CaptchaElement.tsx b/packages/ui/src/elements/CaptchaElement.tsx index b31e0e3d63a..4f0dd6311b2 100644 --- a/packages/ui/src/elements/CaptchaElement.tsx +++ b/packages/ui/src/elements/CaptchaElement.tsx @@ -35,6 +35,7 @@ export const CaptchaElement = ({ // The observer is set up once (`[]` deps), so it reads the latest callback through a ref. const onInteractiveChangeRef = useRef(onInteractiveChange); onInteractiveChangeRef.current = onInteractiveChange; + const isInteractiveRef = useRef(false); const { parsedCaptcha } = useAppearance(); const { locale } = useLocalizations(); const captchaTheme = parsedCaptcha?.theme; @@ -66,8 +67,11 @@ export const CaptchaElement = ({ // rather than the literal `'0'` — otherwise the collapse reads as a non-`'0'` string and the // spotlight stays stuck (blank card) after the challenge resolves. const nowInteractive = parseFloat(maxHeightValueRef.current) > 0 || maxHeightValueRef.current === 'unset'; - setIsInteractive(nowInteractive); - onInteractiveChangeRef.current?.(nowInteractive); + if (nowInteractive !== isInteractiveRef.current) { + isInteractiveRef.current = nowInteractive; + setIsInteractive(nowInteractive); + onInteractiveChangeRef.current?.(nowInteractive); + } } }); }); From 39763d0aea63cb182104584114a809372ca6ffc7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 18 Jun 2026 07:57:30 -0400 Subject: [PATCH 09/11] handle feedback --- .../clerk-js/src/utils/captcha/turnstile.ts | 2 ++ packages/ui/src/elements/CaptchaElement.tsx | 33 +++++++++---------- .../__tests__/CaptchaElement.test.tsx | 2 +- packages/ui/src/test/captcha.ts | 24 +++++--------- 4 files changed, 27 insertions(+), 34 deletions(-) diff --git a/packages/clerk-js/src/utils/captcha/turnstile.ts b/packages/clerk-js/src/utils/captcha/turnstile.ts index 728f6b30094..228bfd85d8c 100644 --- a/packages/clerk-js/src/utils/captcha/turnstile.ts +++ b/packages/clerk-js/src/utils/captcha/turnstile.ts @@ -177,6 +177,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { // and then expands to the correct height visibleWidget.style.minHeight = captchaSize === 'compact' ? '140px' : '68px'; visibleWidget.style.marginBottom = '1.5rem'; + visibleWidget.dataset.clInteractive = 'true'; } } }, @@ -242,6 +243,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { if (captchaTypeUsed === 'smart') { const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID); if (visibleWidget) { + delete visibleWidget.dataset.clInteractive; visibleWidget.style.maxHeight = '0'; visibleWidget.style.minHeight = 'unset'; visibleWidget.style.marginBottom = 'unset'; diff --git a/packages/ui/src/elements/CaptchaElement.tsx b/packages/ui/src/elements/CaptchaElement.tsx index 4f0dd6311b2..3d8ed9013f8 100644 --- a/packages/ui/src/elements/CaptchaElement.tsx +++ b/packages/ui/src/elements/CaptchaElement.tsx @@ -5,13 +5,13 @@ import { Box, useAppearance, useLocalizations } from '../customizables'; /** * 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 + * which operates outside the React lifecycle. It stores the observed state in refs 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. * - * When Turnstile escalates to an interactive "Verify you are human" challenge it sets the element's - * `maxHeight` to a non-`'0'` value (reset back to `'0'` on resolve/error). `onInteractiveChange` surfaces - * that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount. + * When Turnstile escalates to an interactive "Verify you are human" challenge it sets + * `data-cl-interactive="true"` on the element (removed on resolve/error). `onInteractiveChange` + * surfaces that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount. */ export const CaptchaElement = ({ onInteractiveChange, @@ -52,21 +52,20 @@ export const CaptchaElement = ({ const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { const target = mutation.target as HTMLDivElement; - if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) { + if (mutation.type !== 'attributes' || !elementRef.current) { + return; + } + if (mutation.attributeName === 'style') { + // Keep refs in sync so Turnstile's injected styles survive React re-renders. maxHeightValueRef.current = target.style.maxHeight || '0'; minHeightValueRef.current = target.style.minHeight || 'unset'; marginBottomValueRef.current = target.style.marginBottom || 'unset'; - - // ORDERING IS LOAD-BEARING: derive and apply the interactive signal only AFTER the refs above - // are updated. The re-render that `setIsInteractive` triggers reads those refs fresh; setting - // state earlier (or from a separate effect) would write stale defaults back into `style` and - // clobber Turnstile's injected styles. - // - // Turnstile expands the widget by setting `maxHeight: 'unset'` and collapses it back to `'0'`. - // Browsers serialize that `'0'` to `'0px'` when read back, so compare the PARSED length to zero - // rather than the literal `'0'` — otherwise the collapse reads as a non-`'0'` string and the - // spotlight stays stuck (blank card) after the challenge resolves. - const nowInteractive = parseFloat(maxHeightValueRef.current) > 0 || maxHeightValueRef.current === 'unset'; + } + if (mutation.attributeName === 'data-cl-interactive') { + // ORDERING IS LOAD-BEARING: style mutations from the same turnstile.ts call are + // delivered before this one (DOM mutations are batched and replayed in order), so + // the refs above are already up-to-date when the re-render triggered below runs. + const nowInteractive = target.dataset.clInteractive === 'true'; if (nowInteractive !== isInteractiveRef.current) { isInteractiveRef.current = nowInteractive; setIsInteractive(nowInteractive); @@ -78,7 +77,7 @@ export const CaptchaElement = ({ observer.observe(elementRef.current, { attributes: true, - attributeFilter: ['style'], + attributeFilter: ['style', 'data-cl-interactive'], }); return () => observer.disconnect(); diff --git a/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx b/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx index 2305ab99f2b..6f10955889a 100644 --- a/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx +++ b/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx @@ -58,6 +58,6 @@ describe('CaptchaElement', () => { render(, { wrapper }); expect(() => simulateCaptchaInteractive(getCaptcha())).not.toThrow(); - await waitFor(() => expect((getCaptcha().style.maxHeight || '0') !== '0').toBe(true)); + await waitFor(() => expect(getCaptcha().dataset.clInteractive).toBe('true')); }); }); diff --git a/packages/ui/src/test/captcha.ts b/packages/ui/src/test/captcha.ts index 2f491b3bea4..a24d3471055 100644 --- a/packages/ui/src/test/captcha.ts +++ b/packages/ui/src/test/captcha.ts @@ -4,29 +4,21 @@ * Not bundled: this module lives under `src/test/` (the `@/test/*` alias resolves * only during test runs) and is never exported from the package entry. * - * The interactive signal: our Turnstile logic (`turnstile.ts`) mutates the inline - * `#clerk-captcha` element's `style.maxHeight` outside the React lifecycle — - * `'unset'` (or any non-`'0'` value) while an interactive "Verify you are human" - * challenge is showing, back to `'0'` once it resolves. `CaptchaElement` observes - * this via a MutationObserver. These helpers reproduce that transition so tests - * don't hand-roll the fiddly style mutation inline. + * The interactive signal: our Turnstile logic (`turnstile.ts`) sets + * `data-cl-interactive="true"` on `#clerk-captcha` when an interactive challenge + * is showing and removes it on resolve/error. `CaptchaElement` observes this via a + * MutationObserver. These helpers reproduce that transition so tests don't hand-roll + * the DOM mutation inline. */ /** Simulate Turnstile escalating to an interactive challenge (widget expands). */ export const simulateCaptchaInteractive = (el: HTMLElement) => { - el.style.maxHeight = 'unset'; + el.dataset.clInteractive = 'true'; }; -/** - * Simulate the interactive challenge resolving (widget collapses back). - * - * `turnstile.ts` resets `maxHeight` to `'0'`, but browsers serialize that to `'0px'` - * when it's read back off `style` — so collapse with `'0px'` to mirror what the - * MutationObserver actually sees in a real browser (jsdom would keep a literal `'0'` - * and hide the off-by-serialization bug this guards against). - */ +/** Simulate the interactive challenge resolving (widget collapses back). */ export const simulateCaptchaResolved = (el: HTMLElement) => { - el.style.maxHeight = '0px'; + delete el.dataset.clInteractive; }; /** From 68ffad8856763a051ef08d722c50624c419cbe56 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 18 Jun 2026 10:11:15 -0400 Subject: [PATCH 10/11] fix(ui): fall back to maxHeight heuristic for old clerk-js without data-cl-interactive When the attribute is absent (old clerk-js in the wild), infer interactive state from maxHeight so the spotlight still fires. New clerk-js sets the attribute alongside the style; because MutationObserver delivers all mutations from the same microtask before the callback runs, the dataset check is always current and new+new uses the clean attribute contract. --- packages/ui/src/elements/CaptchaElement.tsx | 14 +++++++++ .../__tests__/CaptchaElement.test.tsx | 31 ++++++++++++++++++- packages/ui/src/test/captcha.ts | 19 ++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/elements/CaptchaElement.tsx b/packages/ui/src/elements/CaptchaElement.tsx index 3d8ed9013f8..0b765952cdf 100644 --- a/packages/ui/src/elements/CaptchaElement.tsx +++ b/packages/ui/src/elements/CaptchaElement.tsx @@ -60,6 +60,20 @@ export const CaptchaElement = ({ maxHeightValueRef.current = target.style.maxHeight || '0'; minHeightValueRef.current = target.style.minHeight || 'unset'; marginBottomValueRef.current = target.style.marginBottom || 'unset'; + // Fallback for old clerk-js that never writes data-cl-interactive: infer + // interactive state from maxHeight. When the MutationObserver callback fires, + // the DOM already reflects all mutations from the same microtask, so + // `target.dataset.clInteractive` is up-to-date — new clerk-js (which sets + // the attribute alongside the style) passes the guard and is handled below. + if (!('clInteractive' in target.dataset)) { + const mh = target.style.maxHeight; + const nowInteractive = mh !== '' && mh !== '0' && mh !== '0px'; + if (nowInteractive !== isInteractiveRef.current) { + isInteractiveRef.current = nowInteractive; + setIsInteractive(nowInteractive); + onInteractiveChangeRef.current?.(nowInteractive); + } + } } if (mutation.attributeName === 'data-cl-interactive') { // ORDERING IS LOAD-BEARING: style mutations from the same turnstile.ts call are diff --git a/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx b/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx index 6f10955889a..6b49f425cf3 100644 --- a/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx +++ b/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx @@ -1,7 +1,12 @@ import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { describe, expect, it, vi } from 'vitest'; -import { simulateCaptchaInteractive, simulateCaptchaResolved } from '@/test/captcha'; +import { + simulateCaptchaInteractive, + simulateCaptchaInteractiveLegacy, + simulateCaptchaResolved, + simulateCaptchaResolvedLegacy, +} from '@/test/captcha'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, waitFor } from '@/test/utils'; @@ -60,4 +65,28 @@ describe('CaptchaElement', () => { expect(() => simulateCaptchaInteractive(getCaptcha())).not.toThrow(); await waitFor(() => expect(getCaptcha().dataset.clInteractive).toBe('true')); }); + + describe('legacy clerk-js fallback (no data-cl-interactive attribute)', () => { + it('calls onInteractiveChange(true) when maxHeight expands without the attribute', async () => { + const { wrapper } = await createFixtures(); + const onInteractiveChange = vi.fn(); + render(, { wrapper }); + + simulateCaptchaInteractiveLegacy(getCaptcha()); + + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(true)); + }); + + it('calls onInteractiveChange(false) when maxHeight collapses without the attribute', async () => { + const { wrapper } = await createFixtures(); + const onInteractiveChange = vi.fn(); + render(, { wrapper }); + + simulateCaptchaInteractiveLegacy(getCaptcha()); + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(true)); + + simulateCaptchaResolvedLegacy(getCaptcha()); + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(false)); + }); + }); }); diff --git a/packages/ui/src/test/captcha.ts b/packages/ui/src/test/captcha.ts index a24d3471055..7568831ea6d 100644 --- a/packages/ui/src/test/captcha.ts +++ b/packages/ui/src/test/captcha.ts @@ -21,6 +21,25 @@ export const simulateCaptchaResolved = (el: HTMLElement) => { delete el.dataset.clInteractive; }; +/** + * Simulate old clerk-js escalating to interactive via maxHeight only (no data-cl-interactive). + * Uses a concrete pixel value so the mutation fires reliably in JSDOM. + */ +export const simulateCaptchaInteractiveLegacy = (el: HTMLElement) => { + el.style.maxHeight = '68px'; + el.style.minHeight = '68px'; + el.style.marginBottom = '1.5rem'; +}; + +/** + * Simulate old clerk-js collapsing after resolve via maxHeight only (no data-cl-interactive). + */ +export const simulateCaptchaResolvedLegacy = (el: HTMLElement) => { + el.style.maxHeight = '0'; + el.style.minHeight = ''; + el.style.marginBottom = ''; +}; + /** * Cloudflare's documented dummy sitekeys for testing Turnstile. * @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/ From 043d920df5e45c2b9df14703a7e22ecd48cbcf70 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 18 Jun 2026 10:15:10 -0400 Subject: [PATCH 11/11] chore(release): add changeset for clerk-js captcha spotlight compat fix --- .changeset/captcha-spotlight-compat.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/captcha-spotlight-compat.md diff --git a/.changeset/captcha-spotlight-compat.md b/.changeset/captcha-spotlight-compat.md new file mode 100644 index 00000000000..1f1f82608f7 --- /dev/null +++ b/.changeset/captcha-spotlight-compat.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix the inline captcha spotlight to expand correctly when an older clerk-js runtime is paired with a newer UI that expects the `data-cl-interactive` attribute — the challenge now falls back to the `maxHeight` heuristic so the spotlight still fires across version skews.