diff --git a/.changeset/protect-check-standalone-signup.md b/.changeset/protect-check-standalone-signup.md new file mode 100644 index 00000000000..a056f4268ee --- /dev/null +++ b/.changeset/protect-check-standalone-signup.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fix standalone `` Protect checks so the verification card stays mounted while a solved challenge routes to the next step, while stale direct visits to the protect-check route return to the start of the sign-up flow. diff --git a/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx index 67b9f5615e7..9e1fe542a9b 100644 --- a/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx +++ b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx @@ -1,6 +1,6 @@ import { useClerk } from '@clerk/shared/react'; import type { SignUpProps, SignUpResource } from '@clerk/shared/types'; -import type { ComponentType } from 'react'; +import { type ComponentType, useEffect, useRef, useState } from 'react'; import { Card } from '@/ui/elements/Card'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; @@ -19,6 +19,7 @@ import { Spinner, useLocalizations, } from '../../customizables'; +import { useNavigateToFlowStart } from '../../hooks/useNavigateToFlowStart'; import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner'; import { useRouter } from '../../router'; import { completeSignUpFlow } from './util'; @@ -42,13 +43,31 @@ function SignUpProtectCheckInternal({ verifyPhonePath = '../verify-phone-number', continuePath = '../continue', protectCheckPath = '.', -}: SignUpProtectCheckProps = {}): JSX.Element { +}: SignUpProtectCheckProps = {}): JSX.Element | null { const card = useCardState(); const { t } = useLocalizations(); const signUp = useCoreSignUp(); const { navigate } = useRouter(); + const { navigateToFlowStart } = useNavigateToFlowStart(); const { setActive } = useClerk(); const { afterSignUpUrl, navigateOnSetActive } = useSignUpContext(); + // Latches that a protect check existed at some point, so the resolution race + // (submitProtectCheck clearing protectCheck mid-navigation) isn't mistaken for + // a stale visit. State adjusted during render (guarded) rather than a ref + // write, which React disallows in the render body. + const [everSawProtectCheck, setEverSawProtectCheck] = useState(!!signUp.protectCheck); + const didStartNoCheckFallbackRef = useRef(false); + + if (signUp.protectCheck && !everSawProtectCheck) { + setEverSawProtectCheck(true); + } + + useEffect(() => { + if (!signUp.protectCheck && !everSawProtectCheck && !didStartNoCheckFallbackRef.current) { + didStartNoCheckFallbackRef.current = true; + void navigateToFlowStart(); + } + }, [everSawProtectCheck, navigateToFlowStart, signUp.protectCheck]); const { containerRef, isRunning, hasError, retry } = useProtectCheckRunner({ getProtectCheck: () => signUp.protectCheck, @@ -81,6 +100,13 @@ function SignUpProtectCheckInternal({ }, }); + // Stale/direct visit that never had a check: 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 (!signUp.protectCheck && !everSawProtectCheck) { + return null; + } + return ( diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx index cd0f9196935..a32a29b717d 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { fireEvent, render } from '@/test/utils'; +import { SignUp } from '../index'; import { SignUpProtectCheck } from '../SignUpProtectCheck'; vi.mock('@clerk/shared/internal/clerk-js/protectCheck', () => ({ @@ -34,6 +35,46 @@ describe('SignUpProtectCheck', () => { expect(await findByText(/verifying your request/i)).toBeInTheDocument(); }); + it('keeps the standalone protect-check route mounted after protectCheck clears', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignUpWithProtectCheck(); + }); + fixtures.router.currentPath = '/sign-up/protect-check'; + fixtures.router.fullPath = '/sign-up'; + fixtures.router.indexPath = '/sign-up'; + fixtures.router.matches.mockImplementation((path?: string) => path === 'protect-check'); + mockExecute.mockReturnValue(new Promise(() => {})); + + const { findByText, queryByText, rerender } = render(, { wrapper }); + + expect(await findByText(/verifying your request/i)).toBeInTheDocument(); + + (fixtures.signUp as any).protectCheck = null; + (fixtures.signUp as any).missingFields = []; + rerender(); + + expect(queryByText(/verifying your request/i)).toBeInTheDocument(); + expect(fixtures.router.navigate).not.toHaveBeenCalledWith('/sign-up'); + }); + + it('routes stale standalone protect-check visits back to the flow start', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignUpWithEmailAddress(); + }); + fixtures.router.currentPath = '/sign-up/protect-check'; + fixtures.router.fullPath = '/sign-up'; + fixtures.router.indexPath = '/sign-up'; + + const { queryByText } = render(, { wrapper }); + + // The card shell must not flash while the redirect below kicks in. + expect(queryByText(/verifying your request/i)).not.toBeInTheDocument(); + + await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('/sign-up')); + expect(mockExecute).not.toHaveBeenCalled(); + expect(queryByText(/verifying your request/i)).not.toBeInTheDocument(); + }); + it('runs the SDK challenge with the URL and resource and submits the proof token', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.startSignUpWithProtectCheck({ sdkUrl: 'https://protect.example.com/v1.js' }); diff --git a/packages/ui/src/components/SignUp/index.tsx b/packages/ui/src/components/SignUp/index.tsx index 598dfc6c2d6..a6c72b24b2c 100644 --- a/packages/ui/src/components/SignUp/index.tsx +++ b/packages/ui/src/components/SignUp/index.tsx @@ -35,10 +35,11 @@ function SignUpRoutes(): JSX.Element { return ( - !!clerk.client.signUp.protectCheck} - > + {/* No canActivate guard here. `!!signUp.protectCheck` flips to false + when submitProtectCheck resolves and clears protectCheck, which + unmounts this card mid-navigation and blanks the route. The card + owns its own post-resolution routing. */} + - !!clerk.client.signUp.protectCheck} - > + {/* No canActivate guard: same resolution race as the top-level + protect-check route; the card owns its own routing. */} + {/* Under `continue`, the continue index is `..`, not `../continue`. */}