fix: restore WebOTP with root-cause fix for code not filling into OTP inputs
Some checks failed
Deploy Canina / deploy (push) Successful in 2m23s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 0s

The original bug was that the WebOTP effect had [subView, triggerVerifyOtp]
as its dependency array. triggerVerifyOtp is a useCallback that depends on
[fetchProfile, onClose] - both of which can change reference during normal
re-renders. Each reference change caused React to re-run the effect, which
called AbortController.abort() on the still-pending OTP Promise. The user
would see the 'Allow' modal, click Allow, but the Promise was already aborted
so nothing happened.

Fix: use triggerVerifyOtpRef (a stable ref) so the WebOTP effect only depends
on [subView] / [step]. The ref is kept up-to-date via a dedicated sync effect.

Additional improvements:
- 400ms delay before auto-verify so user can see the filled boxes
- toEnglishDigits on otp.code to handle Persian numerals in the response
- Same fix applied to LoginModal (handleVerifyOtpWithCode -> verifyOtpRef)
This commit is contained in:
parsa aghaei 2026-09-05 18:41:05 +03:30
parent 86e8101d8c
commit 1fd9a92dd0
2 changed files with 84 additions and 5 deletions

View File

@ -189,10 +189,51 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
); );
// NOTE: We intentionally do NOT use navigator.credentials.get (WebOTP API) here // Keep a stable ref to triggerVerifyOtp so the WebOTP effect doesn't need
// because it shows an intrusive browser permission modal that is poor UX. // it as a dependency (which was causing premature AbortController cancellation).
// autoComplete="one-time-code" on the first OTP input provides the native const triggerVerifyOtpRef = useRef(triggerVerifyOtp);
// suggestion bar above the keyboard on Android without any permission dialog. useEffect(() => { triggerVerifyOtpRef.current = triggerVerifyOtp; }, [triggerVerifyOtp]);
// WebOTP API - auto-fill OTP from incoming SMS.
// Bug-fix: only depend on [subView] here — NOT triggerVerifyOtp.
// The old implementation had [subView, triggerVerifyOtp] as deps, which meant any
// reference change in triggerVerifyOtp (caused by onClose/fetchProfile re-creation)
// would abort() the pending WebOTP Promise just as the user was clicking "Allow".
// Using a ref breaks the dependency cycle while keeping the callback up-to-date.
useEffect(() => {
if (subView !== "otp-verify" && subView !== "forgot-otp") return;
if (typeof window === "undefined" || !("OTPCredential" in window)) return;
let isMounted = true;
const ac = new AbortController();
const credApi = (navigator as unknown as {
credentials?: { get?: (opts: unknown) => Promise<{ code?: string }> };
}).credentials;
credApi?.get?.({
otp: { transport: ["sms"] },
signal: ac.signal,
}).then((otp) => {
if (!isMounted || !otp) return;
// OTPCredential.code is the extracted code, not the full SMS text.
// Still run toEnglishDigits in case it contains Persian numerals.
const digits = toEnglishDigits(otp.code ?? "").replace(/[^0-9]/g, "").slice(0, 5);
if (digits.length === 5) {
setOtpCode(digits);
// 400 ms delay so the user can see the filled boxes before verification
setTimeout(() => {
if (isMounted) triggerVerifyOtpRef.current(digits);
}, 400);
}
}).catch(() => {});
return () => {
isMounted = false;
ac.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subView]);
const handleOtpChange = (val: string) => { const handleOtpChange = (val: string) => {
const clean = extractOtpFromText(val); const clean = extractOtpFromText(val);

View File

@ -92,7 +92,45 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
// autoComplete="one-time-code" on the first OTP input provides the native // autoComplete="one-time-code" on the first OTP input provides the native
// suggestion bar above the keyboard on Android without any permission dialog. // suggestion bar above the keyboard on Android without any permission dialog.
// Reset modal state when closed or opened // Keep a stable ref so the WebOTP effect below doesn't need handleVerifyOtpWithCode
// as a dependency — that was causing the AbortController to fire prematurely.
const verifyOtpRef = useRef(handleVerifyOtpWithCode);
useEffect(() => { verifyOtpRef.current = handleVerifyOtpWithCode; }, [handleVerifyOtpWithCode]);
// WebOTP API - auto-fill OTP from incoming SMS.
// Only depends on [step] to avoid premature abort when callback references change.
useEffect(() => {
if (step !== "otp") return;
if (typeof window === "undefined" || !("OTPCredential" in window)) return;
let isMounted = true;
const ac = new AbortController();
const credApi = (navigator as unknown as {
credentials?: { get?: (opts: unknown) => Promise<{ code?: string }> };
}).credentials;
credApi?.get?.({
otp: { transport: ["sms"] },
signal: ac.signal,
}).then((otp) => {
if (!isMounted || !otp) return;
const digits = otp.code?.replace(/[^0-9]/g, "").slice(0, 5) ?? "";
if (digits.length === 5) {
setOtpCode(digits);
setTimeout(() => {
if (isMounted) verifyOtpRef.current(digits);
}, 400);
}
}).catch(() => {});
return () => {
isMounted = false;
ac.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
Promise.resolve().then(() => { Promise.resolve().then(() => {