From 058e5db9b5bd1f16a648e7eb6d3072db14edf56f Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 2 Sep 2026 07:01:36 +0800 Subject: [PATCH] fix(chat): make Home pass react-hooks lint after the split eslint-plugin-react-hooks now analyzes the smaller Home, so render-time ref writes and sync effect setState were failing the staging gate. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 +++ frontend/src/app/page.tsx | 124 +++++++++--------- .../src/components/account-dialog-overlay.tsx | 11 +- frontend/src/hooks/use-profile-onboarding.ts | 7 + .../src/hooks/use-rectification-surface.ts | 3 + frontend/src/hooks/use-session-management.ts | 1 + frontend/tests/account-dialog-overlay.test.ts | 19 ++- 7 files changed, 106 insertions(+), 75 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 52db111d..f14544bc 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7215,3 +7215,19 @@ - 相关记录:BUG-456、BUG-460、BUG-461 - 复发自:BUG-460 - 修复版本:待发布 + +## BUG-470 | Home 拆分后 eslint 开始分析 page.tsx,staging quality gate 因 16 条 react-hooks 失败 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:Gitea `backend-quality-gate` `validate`、`frontend/src/app/page.tsx`、账户 overlay、首页 lint +- 用户现象:向 `staging` 推送 `124d3990` 后 quality gate run `2280` 失败。`validate` job `5496` 前端测试 2460/2460 通过,随后 `npm run lint --prefix frontend` 报 `✖ 86 problems (16 errors, 70 warnings)`。`publish` 因 `needs: validate` 跳过。 +- 触发条件:含第三批 Home 拆分的提交进入完整 runner 的 frontend lint。`eslint-plugin-react-hooks` v7 的 compiler 规则开始分析已缩到约 2000 行的 `Home`。 +- 根因:拆分前 `Home` 过大,同一套规则不分析该组件,render 里写 ref、effect 同步 setState、模块级 `{ current }` 赋值和 `Date.now()` 都不会报错。拆分后这些写法变成 16 个 error:`react-hooks/refs`、`react-hooks/immutability`、`react-hooks/set-state-in-effect`、`react-hooks/purity`。模块级 holder 是第三批为打通 hook 循环新加的;其余模式在拆分前已存在,只是当时扫不到。 +- 修复:循环回调改为 `useRef`,在产出它们的自定义 hook 体里写入(与已有 `resumeRectificationSession.current` 相同)。`sessionsRef` 改在 session hook 内同步。`preview` 改为 state,不再在 render 读 `uiPreview.current`。账户 overlay 改为传入 `model` 对象,去掉 render 里累加 epoch / 写 `modelRef`。聊天 actions 改到 `copyAssistantMessage` 之后的 effect。effect 里同步 setState 改 `queueMicrotask`。合盘卡片时间改走 `timestamp()`。 +- 验证:`npm run lint --prefix frontend` 0 error;`./node_modules/.bin/tsc --noEmit`;`frontend/tests/account-dialog-overlay.test.ts`;相关首页/账户合同测试。 +- 防复发:`Home` 不得在 render 里写 `ref.current` 或改模块级 `{ current }`;账户 overlay 不得再靠 `openEpoch` + `modelRef` 在 render 里刷 memo;源码合同锁定 overlay 走 `model` prop、chat actions 只在 effect 里写入。 +- 相关记录:BUG-326、BUG-339、BUG-343、BUG-383 +- 复发自:BUG-326(render 里写 ref);BUG-339(effect 同步 setState) +- 修复版本:待发布 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ce1de7a5..f4c635f8 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -235,16 +235,6 @@ const BirthTimeRectification = dynamic( }, ); -const rectificationSessionOpener = { - current: async (_exactSessionId: string) => {}, -}; -const guidedBirthTimeReady = { - current: (_result: JourneyClientResponse) => {}, -}; -const editDeclaredBirthTimeDetailsHolder = { - current: () => {}, -}; - export default function Home() { const router = useRouter(); const [profile, setProfile] = useState(emptyProfile); @@ -300,6 +290,7 @@ export default function Home() { const [rectificationTurns, setRectificationTurns] = useState([]); const [rectificationEntrySummary, setRectificationEntrySummary] = useState(null); const [hydrated, setHydrated] = useState(false); + const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false); const [profileSaving, setProfileSaving] = useState(false); const [creatingSession, setCreatingSession] = useState(false); const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState(null); @@ -341,8 +332,6 @@ export default function Home() { const sessionSelectionSource = useRef<"user" | "history">("user"); const applySessionPopStateRef = useRef<(search: string) => void>(() => undefined); const chartLibraryLoadedAccount = useRef(""); - const accountOverlayRef = useRef(null); - const accountOverlayEpochRef = useRef(0); const chatActionsRef = useRef({ onFeedback() {}, onCopy() {}, @@ -355,16 +344,18 @@ export default function Home() { const rectificationOpenInFlight = useRef(false); const sessionDetailInFlight = useRef(new Set()); const sessionsRef = useRef(sessions); - sessionsRef.current = sessions; + const rectificationSessionOpenerRef = useRef(async (_exactSessionId: string) => {}); + const guidedBirthTimeReadyRef = useRef((_result: JourneyClientResponse) => {}); + const editDeclaredBirthTimeDetailsRef = useRef(() => {}); const uiPreview = useRef(false); const uiPreviewMode = useRef(null); const birthTimeRevisionPending = useRef(false); const birthTimeGuided = useBirthTimeGuidedJourney({ journey: birthTimeJourney, - preview: process.env.NODE_ENV === "development" && uiPreview.current, + preview: guidedJourneyPreview, onJourney: setBirthTimeJourney, - onReady: (result) => guidedBirthTimeReady.current(result), - onEditBirthTimeDetails: () => editDeclaredBirthTimeDetailsHolder.current(), + onReady: (result) => guidedBirthTimeReadyRef.current(result), + onEditBirthTimeDetails: () => editDeclaredBirthTimeDetailsRef.current(), }); const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0]; @@ -442,7 +433,7 @@ export default function Home() { setActiveChartId, setActiveSessionId, setBirthTimeConsultationConsent, setCreatingSession, setDraft, setDraftEntrypoint, setDraftTheme, setRectificationError, setRequestError, setSessionDetailLoadingId, setSessionFullPrompt, setSessions, uiPreview, visibleSessions, - openRectificationSession: (exactSessionId) => rectificationSessionOpener.current(exactSessionId), + openRectificationSession: (exactSessionId) => rectificationSessionOpenerRef.current(exactSessionId), }); const { @@ -453,9 +444,7 @@ export default function Home() { saveProfile, saveOnboardingName, saveOnboardingBirth, - editDeclaredBirthTimeDetails, saveOnboardingPlace, - completeGuidedBirthTime, retryBirthTimeAssessment, signOut, } = useProfileOnboarding({ @@ -466,15 +455,13 @@ export default function Home() { setBirthTimeConsultationConsent, setBirthTimeError, setBirthTimeJourney, setDraft, setEditingSelfChart, setOnboardingJustCompleted, setOnboardingStep, setPresetMessageLength, setProfile, setProfileDraft, setProfileNotice, setProfileSaving, setRectificationError, - setSigningOut, setStartGreeting, signingOut, uiPreview, + setSigningOut, setStartGreeting, signingOut, uiPreview, + guidedBirthTimeReadyRef, editDeclaredBirthTimeDetailsRef, }); - guidedBirthTimeReady.current = completeGuidedBirthTime; - editDeclaredBirthTimeDetailsHolder.current = editDeclaredBirthTimeDetails; const { refreshRectificationCase, openRectificationFromHomepage, - openRectificationSession, startNewRectification, handleRectificationProfileIncomplete, handleRectificationMessagesChange, @@ -487,9 +474,8 @@ export default function Home() { setRectificationEntrySummary, setRectificationError, setRectificationLoading, setRectificationPendingQuestion, setRectificationReadonly, setRectificationSessionId, setRectificationShouldStartOpening, setRectificationTurns, setSessions, uiPreview, - updateSession, openAccountDialog, refreshAccount, + updateSession, openAccountDialog, refreshAccount, rectificationSessionOpenerRef, }); - rectificationSessionOpener.current = openRectificationSession; useEffect(() => { activeSessionIdRef.current = activeSessionId; @@ -546,16 +532,19 @@ export default function Home() { useEffect(() => { if (!hydrated || !accountId) return; - setActiveChartId(localStorage.getItem(activeChartStorageKey(accountId)) || "self"); + const storedChartId = localStorage.getItem(activeChartStorageKey(accountId)) || "self"; + queueMicrotask(() => setActiveChartId(storedChartId)); }, [accountId, hydrated]); useEffect(() => { const branch = chartLibrarySessionBranch(accountId, chartLibraryLoadedAccount.current); if (branch === "clear" || !accountId) { - setChartLibrary([]); - setSynastryHistory([]); - setActiveChartId("self"); chartLibraryLoadedAccount.current = ""; + queueMicrotask(() => { + setChartLibrary([]); + setSynastryHistory([]); + setActiveChartId("self"); + }); return; } const profileForLibrary: Profile = activeChartId === "self" ? profile : account ? readProfile(account.profile) : profile; @@ -610,11 +599,16 @@ export default function Home() { || chartLibrary.find((item) => item.role === "self"); if (!record) return; if (record.id !== activeChartId) { - setActiveChartId(record.id); localStorage.setItem(activeChartStorageKey(accountId), record.id); } - setProfile((current) => preserveShallowEqual(current, record.profile)); - if (record.role === "self") setProfileDraft((current) => preserveShallowEqual(current, record.profile)); + const nextChartId = record.id !== activeChartId ? record.id : null; + const nextProfile = record.profile; + const syncSelfDraft = record.role === "self"; + queueMicrotask(() => { + if (nextChartId) setActiveChartId(nextChartId); + setProfile((current) => preserveShallowEqual(current, nextProfile)); + if (syncSelfDraft) setProfileDraft((current) => preserveShallowEqual(current, nextProfile)); + }); }, [accountId, activeChartId, chartLibrary, hydrated]); const profileComplete = isProfileComplete(profile); @@ -768,6 +762,7 @@ export default function Home() { if (previewMode) { uiPreview.current = true; uiPreviewMode.current = previewMode; + setGuidedJourneyPreview(true); if (previewMode === "error") { setAccountError("连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。"); setHydrated(true); @@ -1198,11 +1193,14 @@ export default function Home() { const stored = readStoredDailyStarlanguage(accountId); const controller = new AbortController(); let retryTimer: ReturnType | undefined; - if (stored && stored.day === today && stored.fingerprint === fingerprint) { - setDailyStarlanguage({ kind: "ready", card: stored.card }); - } else { - setDailyStarlanguage({ kind: "pending" }); - } + const cachedCard = stored && stored.day === today && stored.fingerprint === fingerprint + ? stored.card + : null; + queueMicrotask(() => { + if (controller.signal.aborted) return; + if (cachedCard) setDailyStarlanguage({ kind: "ready", card: cachedCard }); + else setDailyStarlanguage({ kind: "pending" }); + }); const attempt = (remainingRetries: number) => { void fetchDailyStarlanguage(controller.signal) .then((next) => { @@ -1393,7 +1391,7 @@ export default function Home() { ? `已完成基础商业合作证据筛查:${layers};声明状态:${payload.claimStatus || "partial"};未用层:${(payload.blockedLayers || []).join(" / ") || "A10 / 双方 Dasha-Narayana / 功能吉凶"}。请勿将其表述为合作保证或精确时点。` : `已计算基础合盘证据:${layers};Ashtakoot ${score ?? "?"}/${max ?? "?"},初步评级:${assessment || "待解释"}。请基于这个证据包继续分析。`; const reportCard: SynastryReportCard = { - id: `${record.id}-${Date.now()}`, + id: `${record.id}-${timestamp()}`, partnerName: record.profile.name || "对方", score, maxScore: max, @@ -1403,7 +1401,7 @@ export default function Home() { strengths: payload.relationshipReport?.strengths, risks: payload.relationshipReport?.risks, nextEvidence: payload.relationshipReport?.nextEvidence, - createdAt: Date.now(), + createdAt: timestamp(), }; let savedReportCard = reportCard; let historyPersisted = !accountId; @@ -1460,6 +1458,28 @@ export default function Home() { } } + useEffect(() => { + chatActionsRef.current = { + onFeedback(feedbackKey, requested) { + setMessageFeedback((current) => { + const next = { ...current }; + const value = toggleChatMessageFeedback(current[feedbackKey], requested); + if (value) next[feedbackKey] = value; + else delete next[feedbackKey]; + return next; + }); + }, + onCopy(feedbackKey, text) { + void copyAssistantMessage(feedbackKey, text); + }, + onRegenerate(renderKey) { + regenerateLatestAnswer(renderKey); + }, + onFollowUp(question) { + void send(question, activeSession?.theme); + }, + }; + }); function submit(event: FormEvent) { event.preventDefault(); @@ -1529,28 +1549,7 @@ export default function Home() { })); const modalOpen = activeAccountDialog !== null || onboardingPaywallOpen; - chatActionsRef.current = { - onFeedback(feedbackKey, requested) { - setMessageFeedback((current) => { - const next = { ...current }; - const value = toggleChatMessageFeedback(current[feedbackKey], requested); - if (value) next[feedbackKey] = value; - else delete next[feedbackKey]; - return next; - }); - }, - onCopy(feedbackKey, text) { - void copyAssistantMessage(feedbackKey, text); - }, - onRegenerate(renderKey) { - regenerateLatestAnswer(renderKey); - }, - onFollowUp(question) { - void send(question, activeSession?.theme); - }, - }; - if (activeAccountDialog !== null) accountOverlayEpochRef.current += 1; - accountOverlayRef.current = { + const accountOverlayModel: AccountOverlayModel | null = activeAccountDialog === null ? null : { title: activeAccountDialog ? accountDialogTitles[activeAccountDialog] : "", dialogClass: activeAccountDialog ? accountDialogClasses[activeAccountDialog] : "", signingOut, @@ -1981,8 +1980,7 @@ export default function Home() { {onboardingPaywallOpen && account && ( ; + model: AccountOverlayModel | null; }>) { - if (!open || dialog === null) return null; - const model = modelRef.current; - if (!model) return null; - void openEpoch; + if (!open || dialog === null || model === null) return null; const isSettingsDialog = dialog !== "logout"; const renderSettingsContent = () => { diff --git a/frontend/src/hooks/use-profile-onboarding.ts b/frontend/src/hooks/use-profile-onboarding.ts index b63edaba..f5695264 100644 --- a/frontend/src/hooks/use-profile-onboarding.ts +++ b/frontend/src/hooks/use-profile-onboarding.ts @@ -87,6 +87,8 @@ export type ProfileOnboardingParams = { setStartGreeting: Dispatch>; signingOut: boolean; uiPreview: MutableRefObject; + guidedBirthTimeReadyRef: MutableRefObject<(result: JourneyClientResponse) => void>; + editDeclaredBirthTimeDetailsRef: MutableRefObject<() => void>; }; export function useProfileOnboarding(params: ProfileOnboardingParams) { @@ -127,6 +129,8 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { setStartGreeting, signingOut, uiPreview, + guidedBirthTimeReadyRef, + editDeclaredBirthTimeDetailsRef, } = params; async function refreshAccount() { @@ -405,6 +409,9 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { } } + guidedBirthTimeReadyRef.current = completeGuidedBirthTime; + editDeclaredBirthTimeDetailsRef.current = editDeclaredBirthTimeDetails; + return { refreshAccount, openAccountDialog, diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts index ad90be02..fd47bef6 100644 --- a/frontend/src/hooks/use-rectification-surface.ts +++ b/frontend/src/hooks/use-rectification-surface.ts @@ -64,6 +64,7 @@ export type RectificationSurfaceParams = { updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void; openAccountDialog: (dialog: AccountDialog, returnTarget?: HTMLButtonElement | null) => void; refreshAccount: () => Promise; + rectificationSessionOpenerRef: MutableRefObject<(exactSessionId: string) => Promise>; }; export function useRectificationSurface(params: RectificationSurfaceParams) { @@ -103,6 +104,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { updateSession, openAccountDialog, refreshAccount, + rectificationSessionOpenerRef, } = params; async function refreshRectificationEntrySummary() { @@ -267,6 +269,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { sessionSelectionSource.current = "history"; void openRectificationSession(session.id); }; + rectificationSessionOpenerRef.current = openRectificationSession; function handleRectificationProfileIncomplete() { setRectificationError("profile_incomplete"); diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index e2a368df..e6c9e788 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -117,6 +117,7 @@ export function useSessionManagement(params: SessionManagementParams) { visibleSessions, openRectificationSession, } = params; + sessionsRef.current = sessions; function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) { setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); diff --git a/frontend/tests/account-dialog-overlay.test.ts b/frontend/tests/account-dialog-overlay.test.ts index 9a62c6d2..27919f71 100644 --- a/frontend/tests/account-dialog-overlay.test.ts +++ b/frontend/tests/account-dialog-overlay.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { createElement, createRef } from "react"; import { renderToString } from "react-dom/server"; import test from "node:test"; @@ -11,10 +12,9 @@ import { test("a closed account overlay does not render profile or logout content", () => { const overlayRef = createRef() as AccountOverlayModel["overlayRef"]; const closeButtonRef = createRef() as AccountOverlayModel["closeButtonRef"]; - const modelRef = createRef() as { current: AccountOverlayModel | null }; let profileRenders = 0; let logoutRenders = 0; - modelRef.current = { + const model: AccountOverlayModel = { title: "个人资料", dialogClass: "profile-modal", signingOut: false, @@ -42,11 +42,22 @@ test("a closed account overlay does not render profile or logout content", () => const html = renderToString(createElement(AccountDialogOverlay, { open: false, dialog: null, - openEpoch: 0, - modelRef, + model, })); assert.equal(html, ""); assert.equal(profileRenders, 0); assert.equal(logoutRenders, 0); }); + +test("home does not write overlay epoch or chat actions during render", () => { + const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8"); + assert.match(overlay, /model: AccountOverlayModel \| null/); + assert.doesNotMatch(overlay, /openEpoch/); + assert.doesNotMatch(overlay, /modelRef/); + assert.doesNotMatch(page, /accountOverlayEpochRef/); + assert.doesNotMatch(page, /sessionsRef\.current = sessions/); + assert.match(page, /queueMicrotask\(\(\) => setActiveChartId\(storedChartId\)\)/); + assert.match(page, /useEffect\(\(\) => \{\s*chatActionsRef\.current = \{/); +});