Skip to content

Commit 9b9d831

Browse files
committed
fix turnstile challenge rendering
1 parent 12dff49 commit 9b9d831

1 file changed

Lines changed: 116 additions & 50 deletions

File tree

src/components/SignUpForm/SignUpForm.tsx

Lines changed: 116 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { FIELD_CONFIGS, validateName } from "@/lib/formValidation";
1616
import { getStoredAttributionParams } from "@/utils/attributionUtils";
1717
import { isTurnstileEnabled } from "@/utils/utils";
1818
import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
19-
import { useCallback, useEffect, useRef, useState } from "react";
19+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2020

2121
interface SignUpFormProps {
2222
defaultValues?: {
@@ -27,10 +27,13 @@ interface SignUpFormProps {
2727
error?: string;
2828
}
2929

30-
const TOKEN_TIMEOUT = 10000;
30+
const BACKGROUND_TOKEN_TIMEOUT = 10000;
31+
const INTERACTIVE_TOKEN_TIMEOUT = 120000;
3132
const EXPIRED_MESSAGE = "Verification expired. Please complete it again.";
3233
const TIMEOUT_MESSAGE =
3334
"Security check didn’t complete. Try disabling ad blockers and then try again. If it still fails, try a different browser or network.";
35+
const UNSUPPORTED_MESSAGE =
36+
"This browser can’t complete the security check. Please try a different browser or network.";
3437

3538
export default function SignUpForm({
3639
defaultValues = {},
@@ -41,60 +44,80 @@ export default function SignUpForm({
4144

4245
const [captchaError, setCaptchaError] = useState<string | null>(null);
4346
const [isWaitingForToken, setIsWaitingForToken] = useState(false);
44-
const [showTurnstileField, setShowTurnstileField] = useState(false);
47+
const [isTurnstileInteractive, setIsTurnstileInteractive] = useState(false);
4548

4649
const turnstileRef = useRef<TurnstileInstance>(null);
4750
const tokenResolverRef = useRef<((token: string) => void) | null>(null);
4851
const tokenRejecterRef = useRef<((error: Error) => void) | null>(null);
52+
const tokenTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
4953

54+
const turnstileEnabled = isTurnstileEnabled();
5055
const hasFieldErrors = Boolean(firstNameError || captchaError);
5156

52-
// Only show field wrapper when there's an error (invisible mode handles normal flow)
53-
useEffect(() => {
54-
setShowTurnstileField(!!captchaError);
55-
}, [captchaError]);
57+
const clearTokenTimeout = useCallback(() => {
58+
if (tokenTimeoutRef.current) {
59+
clearTimeout(tokenTimeoutRef.current);
60+
tokenTimeoutRef.current = null;
61+
}
62+
}, []);
63+
64+
const clearTokenPromise = useCallback(() => {
65+
tokenResolverRef.current = null;
66+
tokenRejecterRef.current = null;
67+
clearTokenTimeout();
68+
}, [clearTokenTimeout]);
69+
70+
const rejectTokenPromise = useCallback(
71+
(errorMessage: string) => {
72+
if (tokenRejecterRef.current) {
73+
tokenRejecterRef.current(new Error(errorMessage));
74+
}
75+
clearTokenPromise();
76+
},
77+
[clearTokenPromise]
78+
);
79+
80+
const scheduleTokenTimeout = useCallback(
81+
(timeout: number, errorMessage = TIMEOUT_MESSAGE) => {
82+
clearTokenTimeout();
83+
tokenTimeoutRef.current = setTimeout(() => {
84+
rejectTokenPromise(errorMessage);
85+
}, timeout);
86+
},
87+
[clearTokenTimeout, rejectTokenPromise]
88+
);
5689

5790
// Promise-based token wait mechanism with timeout
5891
const waitForToken = useCallback(
59-
(timeout = TOKEN_TIMEOUT): Promise<string> => {
92+
(timeout = BACKGROUND_TOKEN_TIMEOUT): Promise<string> => {
6093
return new Promise((resolve, reject) => {
6194
// Store resolvers for onSuccess/onError callbacks
6295
tokenResolverRef.current = resolve;
6396
tokenRejecterRef.current = reject;
6497

65-
// Timeout after specified duration
66-
setTimeout(() => {
67-
if (tokenRejecterRef.current) {
68-
tokenRejecterRef.current(new Error(TIMEOUT_MESSAGE));
69-
tokenResolverRef.current = null;
70-
tokenRejecterRef.current = null;
71-
}
72-
}, timeout);
98+
scheduleTokenTimeout(timeout);
7399
});
74100
},
75-
[]
101+
[scheduleTokenTimeout]
76102
);
77103

78-
const resolveTokenPromise = useCallback((token: string) => {
79-
if (tokenResolverRef.current) {
80-
tokenResolverRef.current(token);
81-
tokenResolverRef.current = null;
82-
tokenRejecterRef.current = null;
83-
}
84-
}, []);
104+
const resolveTokenPromise = useCallback(
105+
(token: string) => {
106+
if (tokenResolverRef.current) {
107+
tokenResolverRef.current(token);
108+
}
109+
clearTokenPromise();
110+
},
111+
[clearTokenPromise]
112+
);
85113

86-
const rejectTokenPromise = useCallback((errorMessage: string) => {
87-
if (tokenRejecterRef.current) {
88-
tokenRejecterRef.current(new Error(errorMessage));
89-
tokenResolverRef.current = null;
90-
tokenRejecterRef.current = null;
91-
}
92-
}, []);
114+
useEffect(() => clearTokenTimeout, [clearTokenTimeout]);
93115

94116
const handleTurnstileSuccess = useCallback(
95117
(token: string) => {
96118
setCaptchaError(null);
97119
setIsWaitingForToken(false);
120+
setIsTurnstileInteractive(false);
98121
resolveTokenPromise(token);
99122
},
100123
[resolveTokenPromise]
@@ -103,6 +126,7 @@ export default function SignUpForm({
103126
const handleTurnstileError = useCallback(
104127
(error: string) => {
105128
setIsWaitingForToken(false);
129+
setIsTurnstileInteractive(false);
106130

107131
const errorMessage = `Security verification failed with error #${error}. Please try again or use a different browser.`;
108132

@@ -114,10 +138,35 @@ export default function SignUpForm({
114138

115139
const handleTurnstileExpire = useCallback(() => {
116140
setIsWaitingForToken(false);
141+
setIsTurnstileInteractive(false);
117142
setCaptchaError(EXPIRED_MESSAGE);
118143
rejectTokenPromise(EXPIRED_MESSAGE);
119144
}, [rejectTokenPromise]);
120145

146+
const handleTurnstileTimeout = useCallback(() => {
147+
setIsWaitingForToken(false);
148+
setIsTurnstileInteractive(false);
149+
setCaptchaError(TIMEOUT_MESSAGE);
150+
rejectTokenPromise(TIMEOUT_MESSAGE);
151+
}, [rejectTokenPromise]);
152+
153+
const handleTurnstileUnsupported = useCallback(() => {
154+
setIsWaitingForToken(false);
155+
setIsTurnstileInteractive(false);
156+
setCaptchaError(UNSUPPORTED_MESSAGE);
157+
rejectTokenPromise(UNSUPPORTED_MESSAGE);
158+
}, [rejectTokenPromise]);
159+
160+
const handleTurnstileBeforeInteractive = useCallback(() => {
161+
setCaptchaError(null);
162+
setIsTurnstileInteractive(true);
163+
scheduleTokenTimeout(INTERACTIVE_TOKEN_TIMEOUT);
164+
}, [scheduleTokenTimeout]);
165+
166+
const handleTurnstileAfterInteractive = useCallback(() => {
167+
setIsTurnstileInteractive(false);
168+
}, []);
169+
121170
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
122171
event.preventDefault();
123172
if (isSubmitting || isWaitingForToken) return;
@@ -137,18 +186,19 @@ export default function SignUpForm({
137186
// Handle Turnstile token generation if enabled
138187
// Always get a fresh token as they are single-use and never reused
139188
let tokenToUse: string | undefined;
140-
if (isTurnstileEnabled()) {
189+
if (turnstileEnabled) {
141190
// Reset any existing token to ensure we get a fresh one
142-
143191
turnstileRef.current?.reset();
144192

145193
setIsWaitingForToken(true);
146194
try {
147-
// Execute Turnstile verification
195+
// Start waiting before execution so a fast success callback cannot race us.
196+
const tokenPromise = waitForToken(BACKGROUND_TOKEN_TIMEOUT);
197+
148198
turnstileRef.current?.execute();
149199

150200
// Wait for token with timeout
151-
tokenToUse = await waitForToken(TOKEN_TIMEOUT);
201+
tokenToUse = await tokenPromise;
152202

153203
// Token obtained, proceed with submission
154204
setIsWaitingForToken(false);
@@ -189,8 +239,31 @@ export default function SignUpForm({
189239
onSuccess: handleTurnstileSuccess,
190240
onError: handleTurnstileError,
191241
onExpire: handleTurnstileExpire,
242+
onTimeout: handleTurnstileTimeout,
243+
onUnsupported: handleTurnstileUnsupported,
244+
onBeforeInteractive: handleTurnstileBeforeInteractive,
245+
onAfterInteractive: handleTurnstileAfterInteractive,
246+
options: {
247+
appearance: "interaction-only",
248+
execution: "execute",
249+
responseField: false,
250+
} as const,
192251
};
193252

253+
const turnstileContainerStyle = useMemo(
254+
() =>
255+
isTurnstileInteractive || captchaError
256+
? undefined
257+
: ({
258+
position: "absolute",
259+
width: 1,
260+
height: 1,
261+
overflow: "hidden",
262+
clipPath: "inset(50%)",
263+
} as const),
264+
[captchaError, isTurnstileInteractive]
265+
);
266+
194267
return (
195268
<Form onSubmit={handleSubmit}>
196269
<Field>
@@ -236,21 +309,14 @@ export default function SignUpForm({
236309
</CheckboxRow>
237310
</CheckboxCluster>
238311

239-
{isTurnstileEnabled() &&
240-
(showTurnstileField ? (
241-
<Field>
242-
<Turnstile {...turnstileProps} options={{ execution: "execute" }} />
243-
{captchaError && (
244-
<InputHint variant="error">{captchaError}</InputHint>
245-
)}
246-
</Field>
247-
) : (
248-
<Turnstile
249-
{...turnstileProps}
250-
options={{ size: "invisible", execution: "execute" }}
251-
style={{ display: "none" }}
252-
/>
253-
))}
312+
{turnstileEnabled && (
313+
<Field style={turnstileContainerStyle}>
314+
<Turnstile {...turnstileProps} />
315+
{captchaError && (
316+
<InputHint variant="error">{captchaError}</InputHint>
317+
)}
318+
</Field>
319+
)}
254320

255321
{(error || hasFieldErrors) && (
256322
<FormMessage

0 commit comments

Comments
 (0)