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)
This commit is contained in:
parent
86e8101d8c
commit
1fd9a92dd0
@ -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);
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user