fix(web): 注册卡片 async 回调加卸载守卫(I8);核实 I7 res.text() 已在 try 内(无需改)

RegisterCard.jsx onSendCode/onRegister 里 await callApi 期间组件若已卸载(用户导航离开
注册页),之后的 setState 会打到已卸载组件——加 mountedRef 守卫,对齐文件内既有
cooldownRef/redirectRef 的 unmount 清理写法。InviteCard.jsx onCopy 的 1.5s setTimeout
从未 clear,存 ref 并在卸载时 clear。

I7(callApi 里 res.text()/res.json() 读 body 阶段断连,声称在 try 之外)经核实:
callApi 自创建(65ce266)起,res.text()+JSON.parse 就已在独立 try/catch 内(279-286行),
断连会被捕获、data 置 null,不会产生 unhandled rejection;未做改动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
This commit is contained in:
wangjia
2026-07-13 15:53:40 +08:00
parent 301e13f477
commit ec4b3e0f22
2 changed files with 14 additions and 2 deletions
+8 -2
View File
@@ -18,7 +18,7 @@
* ——与主页行为一致,用户可用 Header 的语言下拉自行切换(切换会带上当前的邀请码路径,见
* Header.jsx 的 `pagePath` 机制)。
*/
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Copy, Check, Download, UserPlus, Gift, ArrowRight } from 'lucide-react';
import { isLoggedIn } from '../lib/authState';
import { INVITE_CODE_RE } from '../lib/inviteCode';
@@ -163,12 +163,17 @@ export default function InviteCard({ lang = 'en' }) {
const [code, setCode] = useState(null);
const [copied, setCopied] = useState(false);
const [loggedIn, setLoggedIn] = useState(false);
// onCopy 里 1.5s 后把 copied 复位的 setTimeout 存 ref,卸载时 clear——
// 否则用户复制后立刻离开页面,定时器仍会在已卸载组件上触发 setState。
const copyTimeoutRef = useRef(null);
useEffect(() => {
setCode(extractCode(window.location.pathname));
setLoggedIn(isLoggedIn());
}, []);
useEffect(() => () => clearTimeout(copyTimeoutRef.current), []);
const t = COPY[lang] || COPY.en;
const dlHref = useMemo(() => `${langPath(lang)}#download`, [lang]);
const registerHref = useMemo(
@@ -181,7 +186,8 @@ export default function InviteCard({ lang = 'en' }) {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
clearTimeout(copyTimeoutRef.current);
copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
} catch {
// 剪贴板权限失败时静默,用户仍可长按/选中手动复制。
}
@@ -311,6 +311,9 @@ export default function RegisterCard({ lang = 'en' }) {
const [redirectIn, setRedirectIn] = useState(REDIRECT_SECS);
const redirectRef = useRef(null);
const codeInputRef = useRef(null);
// 卸载守卫:onSendCode/onRegister 里 await callApi 期间组件若已卸载(用户导航离开),
// 之后的 setState 直接跳过——避免 unmounted 组件 setState 的告警/竞态。
const mountedRef = useRef(true);
const t = COPY[lang] || COPY.en;
// 后端错误字段只有 message_zh/message_en 两语;非中文一律取英文兜底(见组件顶部注释)。
@@ -334,6 +337,7 @@ export default function RegisterCard({ lang = 'en' }) {
useEffect(() => () => clearInterval(cooldownRef.current), []);
useEffect(() => () => clearInterval(redirectRef.current), []);
useEffect(() => () => { mountedRef.current = false; }, []);
useEffect(() => {
if (codeSent && codeInputRef.current) codeInputRef.current.focus();
@@ -381,6 +385,7 @@ export default function RegisterCard({ lang = 'en' }) {
}
setSendingCode(true);
const r = await callApi('/v1/auth/code', { email: trimmed });
if (!mountedRef.current) return;
setSendingCode(false);
if (!r.ok) {
setCodeErr(r.network ? t.netErr : (r.error && r.error[msgKey]) || t.netErr);
@@ -403,6 +408,7 @@ export default function RegisterCard({ lang = 'en' }) {
const body = { email: trimmedEmail, code, password };
if (invite) body.invite_code = invite;
const r = await callApi('/v1/auth/register', body);
if (!mountedRef.current) return;
setSubmitting(false);
if (!r.ok) {
setRegErr(r.network ? t.netErr : (r.error && r.error[msgKey]) || t.netErr);