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
|
// 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);
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user