From 205ec92e13582f5e29a495c8d77f07ed21bce1b9 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Mon, 31 Aug 2026 13:02:55 -0400 Subject: [PATCH] fix(ui): fetch status-only Protect gates instead of bouncing sign-ins to the flow start A sign-in gated via status 'needs_protect_check' with no inline protectCheck payload was treated as a stale visit by SignInProtectCheck and bounced to the flow start, discarding the in-progress gated sign-in (OAuth callback flows). Reload the sign-in to fetch the challenge; fall back to the flow start only when the reload still yields no gate. Co-Authored-By: Claude Fable 5 --- .../signin-protect-check-status-only-gate.md | 5 +++ .../components/SignIn/SignInProtectCheck.tsx | 33 +++++++++++++-- .../__tests__/SignInProtectCheck.test.tsx | 42 +++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 .changeset/signin-protect-check-status-only-gate.md diff --git a/.changeset/signin-protect-check-status-only-gate.md b/.changeset/signin-protect-check-status-only-gate.md new file mode 100644 index 00000000000..0f4c58db97d --- /dev/null +++ b/.changeset/signin-protect-check-status-only-gate.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fix sign-ins gated by Clerk Protect via `status: 'needs_protect_check'` without an inline `protectCheck` payload bouncing back to the flow start. `` now reloads the sign-in to fetch the challenge and only falls back to the flow start when no gate exists. diff --git a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx index b71845be905..5c32cbcf63f 100644 --- a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx +++ b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx @@ -24,7 +24,11 @@ import { useNavigateToFlowStart } from '../../hooks/useNavigateToFlowStart'; import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner'; import { useRouter } from '../../router'; import { buildSignInOAuthCallbackParams } from './buildOAuthCallbackParams'; -import { isSignInPendingOAuthTransfer, resumeSignInAfterProtectCheck } from './handleProtectCheck'; +import { + isSignInPendingOAuthTransfer, + isSignInProtectGated, + resumeSignInAfterProtectCheck, +} from './handleProtectCheck'; function SignInProtectCheckInternal(): JSX.Element | null { const card = useCardState(); @@ -43,17 +47,37 @@ function SignInProtectCheckInternal(): JSX.Element | null { // persist that a protect check existed at some point const [everSawProtectCheck, setEverSawProtectCheck] = useState(!!signIn.protectCheck); const didStartNoCheckFallbackRef = useRef(false); + // 'none' | 'pending' | 'done' — one bounded reload for a gate signalled by status alone + const [statusOnlyReload, setStatusOnlyReload] = useState<'none' | 'pending' | 'done'>('none'); if (signIn.protectCheck && !everSawProtectCheck) { setEverSawProtectCheck(true); } useEffect(() => { - if (!signIn.protectCheck && !everSawProtectCheck && !didStartNoCheckFallbackRef.current) { + if (signIn.protectCheck || everSawProtectCheck) { + return; + } + // A gate signalled by `status: 'needs_protect_check'` without an inline `protectCheck` + // payload is an in-progress, server-gated sign-in (e.g. gated on an OAuth callback + // exchange) — reload to fetch the challenge; bouncing to the flow start would discard + // the sign-in and force the user to restart. + if (isSignInProtectGated(signIn) && statusOnlyReload === 'none') { + setStatusOnlyReload('pending'); + void signIn + .reload() + .catch(() => {}) + .finally(() => setStatusOnlyReload('done')); + return; + } + if (statusOnlyReload === 'pending') { + return; + } + if (!didStartNoCheckFallbackRef.current) { didStartNoCheckFallbackRef.current = true; void navigateToFlowStart(); } - }, [everSawProtectCheck, navigateToFlowStart, signIn.protectCheck]); + }, [everSawProtectCheck, navigateToFlowStart, signIn, statusOnlyReload]); const { containerRef, isRunning, isWidgetVisible, hasError, retry } = useProtectCheckRunner({ getProtectCheck: () => signIn.protectCheck, @@ -101,7 +125,8 @@ function SignInProtectCheckInternal(): JSX.Element | null { // resolves" guarantee, nor keep a spinner next to the retry button. const showSpinner = useSpinDelay(isRunning, { delay: 300 }); - // Stale/direct visit that never had a check: render nothing while the flow-start redirect + // No challenge payload yet (stale/direct visit, or the status-only reload above is in + // flight): render nothing while the flow-start redirect // scheduled above kicks in, instead of flashing the card shell for one paint. Must stay // below every hook call. if (!signIn.protectCheck && !everSawProtectCheck) { diff --git a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx index d067fbb7396..c6522e3bcce 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx @@ -617,4 +617,46 @@ describe('SignInProtectCheck', () => { await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('/sign-in')); expect(mockExecute).not.toHaveBeenCalled(); }); + it('reloads a status-only gate instead of bouncing to the flow start', async () => { + // `needs_protect_check` can be signalled without an inline `protectCheck` payload + // (e.g. a gate surfacing on an OAuth callback exchange); the challenge arrives on reload. + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + (fixtures.signIn as any).protectCheck = null; + const reload = vi.fn(() => { + (fixtures.signIn as any).protectCheck = { + status: 'pending', + token: 'challenge-token', + sdkUrl: 'https://protect.example.com/sdk.js', + }; + return Promise.resolve(fixtures.signIn as unknown as SignInResource); + }); + (fixtures.signIn as any).reload = reload; + mockExecute.mockReturnValue(new Promise(() => {})); + + const { findByText } = render(, { wrapper }); + + await waitFor(() => expect(reload).toHaveBeenCalled()); + expect(await findByText(/verifying your request/i)).toBeInTheDocument(); + expect(fixtures.router.navigate).not.toHaveBeenCalled(); + }); + + it('falls back to the flow start when the reload still carries no challenge', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + (fixtures.signIn as any).protectCheck = null; + const reload = vi.fn(() => Promise.resolve(fixtures.signIn as unknown as SignInResource)); + (fixtures.signIn as any).reload = reload; + fixtures.router.currentPath = '/sign-in/protect-check'; + fixtures.router.fullPath = '/sign-in'; + fixtures.router.indexPath = '/sign-in'; + + render(, { wrapper }); + + await waitFor(() => expect(reload).toHaveBeenCalled()); + await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('/sign-in')); + expect(mockExecute).not.toHaveBeenCalled(); + }); });