diff --git a/.changeset/protect-check-card.md b/.changeset/protect-check-card.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/protect-check-card.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/ui/src/components/ProtectCheck/ProtectCheckCard.tsx b/packages/ui/src/components/ProtectCheck/ProtectCheckCard.tsx
new file mode 100644
index 00000000000..40746674e65
--- /dev/null
+++ b/packages/ui/src/components/ProtectCheck/ProtectCheckCard.tsx
@@ -0,0 +1,93 @@
+import { Card } from '@/ui/elements/Card';
+import { Header } from '@/ui/elements/Header';
+
+import {
+ Box,
+ Button,
+ Col,
+ descriptors,
+ Flex,
+ Flow,
+ localizationKeys,
+ Spinner,
+ useLocalizations,
+} from '../../customizables';
+import { useSpinDelay } from '../../hooks';
+import type { ProtectCheckRunnerState } from '../../hooks/useProtectCheckRunner';
+
+const localizationKeysByFlow = {
+ signIn: {
+ title: localizationKeys('signIn.protectCheck.title'),
+ subtitle: localizationKeys('signIn.protectCheck.subtitle'),
+ loading: localizationKeys('signIn.protectCheck.loading'),
+ retryButton: localizationKeys('signIn.protectCheck.retryButton'),
+ },
+ signUp: {
+ title: localizationKeys('signUp.protectCheck.title'),
+ subtitle: localizationKeys('signUp.protectCheck.subtitle'),
+ loading: localizationKeys('signUp.protectCheck.loading'),
+ retryButton: localizationKeys('signUp.protectCheck.retryButton'),
+ },
+};
+
+type ProtectCheckCardProps = {
+ flow: 'signIn' | 'signUp';
+ runner: ProtectCheckRunnerState;
+};
+
+export const ProtectCheckCard = ({ flow, runner }: ProtectCheckCardProps) => {
+ const { containerRef, isRunning, isWidgetVisible, error, retry } = runner;
+ const { t } = useLocalizations();
+ const keys = localizationKeysByFlow[flow];
+
+ // Debounce the spinner's entrance so a near-instant check (or a script that signals its
+ // widget immediately) never flashes it — the card header alone carries the first ~300ms.
+ // The error and widget-visibility gates stay OUTSIDE the delay hook below: its minimum
+ // visible duration must never outrank the handshake's "spinner is gone when the promise
+ // resolves" guarantee, nor keep a spinner next to the retry button.
+ const showSpinner = useSpinDelay(isRunning, { delay: 300 });
+
+ return (
+
+
+
+
+
+
+
+ {error}
+
+
+ {showSpinner && !error && !isWidgetVisible ? (
+
+
+
+ ) : null}
+ {error ? (
+
+ ) : null}
+
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
index 80f312e24cd..03e080e4a70 100644
--- a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
+++ b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
@@ -3,28 +3,15 @@ import { useClerk } from '@clerk/shared/react';
import type { SignInResource } from '@clerk/shared/types';
import { useEffect, useRef, useState } from 'react';
-import { Card } from '@/ui/elements/Card';
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
-import { Header } from '@/ui/elements/Header';
import { actionBlockedDetailsFrom } from '@/ui/utils/actionBlocked';
import { ActionBlockedCard, withRedirectToAfterSignIn } from '../../common';
import { useCoreSignIn, useSignInContext } from '../../contexts';
-import {
- Box,
- Button,
- Col,
- descriptors,
- Flex,
- Flow,
- localizationKeys,
- Spinner,
- useLocalizations,
-} from '../../customizables';
-import { useSpinDelay } from '../../hooks';
import { useNavigateToFlowStart } from '../../hooks/useNavigateToFlowStart';
import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner';
import { useRouter } from '../../router';
+import { ProtectCheckCard } from '../ProtectCheck/ProtectCheckCard';
import { buildSignInOAuthCallbackParams } from './buildOAuthCallbackParams';
import {
isProtectCheckRequiredError,
@@ -35,7 +22,6 @@ import {
function SignInProtectCheckInternal(): JSX.Element | null {
const card = useCardState();
- const { t } = useLocalizations();
const signIn = useCoreSignIn();
const { navigate } = useRouter();
const { navigateToFlowStart } = useNavigateToFlowStart();
@@ -62,7 +48,7 @@ function SignInProtectCheckInternal(): JSX.Element | null {
}
}, [everSawProtectCheck, navigateToFlowStart, signIn.protectCheck]);
- const { containerRef, isRunning, isWidgetVisible, hasError, retry } = useProtectCheckRunner({
+ const runner = useProtectCheckRunner({
getProtectCheck: () => signIn.protectCheck,
getResource: () => signIn,
reload: () => signIn.reload(),
@@ -127,13 +113,6 @@ function SignInProtectCheckInternal(): JSX.Element | null {
},
});
- // Debounce the spinner's entrance so a near-instant check (or a script that signals its
- // widget immediately) never flashes it — the card header alone carries the first ~300ms.
- // The error and widget-visibility gates stay OUTSIDE the delay hook below: its minimum
- // visible duration must never outrank the handshake's "spinner is gone when the promise
- // 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
// scheduled above kicks in, instead of flashing the card shell for one paint. Must stay
// below every hook call.
@@ -147,47 +126,10 @@ function SignInProtectCheckInternal(): JSX.Element | null {
}
return (
-
-
-
-
-
-
-
- {card.error}
-
-
- {showSpinner && !hasError && !isWidgetVisible ? (
-
-
-
- ) : null}
- {hasError ? (
-
- ) : null}
-
-
-
-
-
+
);
}
diff --git a/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
index 9654cbef133..dbebc37fa31 100644
--- a/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
+++ b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
@@ -1,27 +1,14 @@
import type { SignUpProps, SignUpResource } from '@clerk/shared/types';
import { type ComponentType, useEffect, useRef, useState } from 'react';
-import { Card } from '@/ui/elements/Card';
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
-import { Header } from '@/ui/elements/Header';
import { actionBlockedDetailsFrom } from '@/ui/utils/actionBlocked';
import { ActionBlockedCard, withRedirectToAfterSignUp } from '../../common';
import { useCoreSignUp } from '../../contexts';
-import {
- Box,
- Button,
- Col,
- descriptors,
- Flex,
- Flow,
- localizationKeys,
- Spinner,
- useLocalizations,
-} from '../../customizables';
-import { useSpinDelay } from '../../hooks';
import { useNavigateToFlowStart } from '../../hooks/useNavigateToFlowStart';
import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner';
+import { ProtectCheckCard } from '../ProtectCheck/ProtectCheckCard';
import { useCompleteSignUpFlow } from './useCompleteSignUpFlow';
/**
@@ -45,7 +32,6 @@ function SignUpProtectCheckInternal({
protectCheckPath = '.',
}: SignUpProtectCheckProps = {}): JSX.Element | null {
const card = useCardState();
- const { t } = useLocalizations();
const signUp = useCoreSignUp();
const { navigateToFlowStart } = useNavigateToFlowStart();
const completeSignUpFlow = useCompleteSignUpFlow();
@@ -67,7 +53,7 @@ function SignUpProtectCheckInternal({
}
}, [everSawProtectCheck, navigateToFlowStart, signUp.protectCheck]);
- const { containerRef, isRunning, isWidgetVisible, hasError, retry } = useProtectCheckRunner({
+ const runner = useProtectCheckRunner({
getProtectCheck: () => signUp.protectCheck,
getResource: () => signUp,
reload: () => signUp.reload(),
@@ -90,13 +76,6 @@ function SignUpProtectCheckInternal({
},
});
- // Debounce the spinner's entrance so a near-instant check (or a script that signals its
- // widget immediately) never flashes it — the card header alone carries the first ~300ms.
- // The error and widget-visibility gates stay OUTSIDE the delay hook (in the JSX below): its
- // minimum visible duration must never outrank the handshake's "spinner is gone when the
- // promise 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 scheduled above kicks in, instead of flashing the card
// shell for one paint. Must stay below every hook call.
@@ -110,47 +89,10 @@ function SignUpProtectCheckInternal({
}
return (
-
-
-
-
-
-
-
- {card.error}
-
-
- {showSpinner && !hasError && !isWidgetVisible ? (
-
-
-
- ) : null}
- {hasError ? (
-
- ) : null}
-
-
-
-
-
+
);
}
diff --git a/packages/ui/src/hooks/useProtectCheckRunner.ts b/packages/ui/src/hooks/useProtectCheckRunner.ts
index ac22da7425d..885fcd98f53 100644
--- a/packages/ui/src/hooks/useProtectCheckRunner.ts
+++ b/packages/ui/src/hooks/useProtectCheckRunner.ts
@@ -19,7 +19,7 @@ export interface ProtectCheckRunnerParams extends ProtectCheckRunnerR
onResolved: (resource: TResource, isCancelled: () => boolean) => Promise;
}
-export interface ProtectCheckRunner {
+export interface ProtectCheckRunnerState {
containerRef: React.MutableRefObject;
isRunning: boolean;
/**
@@ -28,8 +28,8 @@ export interface ProtectCheckRunner {
* callers should hide their own spinner and give the container layout space.
*/
isWidgetVisible: boolean;
- /** Whether the card is currently showing a (recoverable) error. */
- hasError: boolean;
+ /** The (recoverable) error the card is currently showing, if any. */
+ error: string | undefined;
/** Clears the error and re-runs the challenge from scratch. */
retry: () => void;
}
@@ -41,7 +41,7 @@ export interface ProtectCheckRunner {
*
* Must be called from within a `CardStateProvider`.
*/
-export function useProtectCheckRunner(params: ProtectCheckRunnerParams): ProtectCheckRunner {
+export function useProtectCheckRunner(params: ProtectCheckRunnerParams): ProtectCheckRunnerState {
const card = useCardState();
// Override for the module-LOAD bound only (see `executeProtectCheck`), resolved loader first
@@ -252,5 +252,5 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- return { containerRef, isRunning, isWidgetVisible, hasError: !!card.error, retry };
+ return { containerRef, isRunning, isWidgetVisible, error: card.error, retry };
}