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`. */}