From 1fd9a92dd0dffd08e7508501f0cf42dc176ce10c Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Sat, 5 Sep 2026 18:41:05 +0330 Subject: [PATCH] fix: restore WebOTP with root-cause fix for code not filling into OTP inputs 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) --- frontend/application/components/AuthModal.tsx | 49 +++++++++++++++++-- .../application/components/LoginModal.tsx | 40 ++++++++++++++- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/frontend/application/components/AuthModal.tsx b/frontend/application/components/AuthModal.tsx index 60150dd..45ef309 100644 --- a/frontend/application/components/AuthModal.tsx +++ b/frontend/application/components/AuthModal.tsx @@ -189,10 +189,51 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { ); - // NOTE: We intentionally do NOT use navigator.credentials.get (WebOTP API) here - // because it shows an intrusive browser permission modal that is poor UX. - // autoComplete="one-time-code" on the first OTP input provides the native - // suggestion bar above the keyboard on Android without any permission dialog. + // Keep a stable ref to triggerVerifyOtp so the WebOTP effect doesn't need + // it as a dependency (which was causing premature AbortController cancellation). + const triggerVerifyOtpRef = useRef(triggerVerifyOtp); + 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 clean = extractOtpFromText(val); diff --git a/frontend/application/components/LoginModal.tsx b/frontend/application/components/LoginModal.tsx index dae6f65..a0ab295 100644 --- a/frontend/application/components/LoginModal.tsx +++ b/frontend/application/components/LoginModal.tsx @@ -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 // 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(() => { if (!isOpen) { Promise.resolve().then(() => {