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(() => {