From a4ab229e8468a77e777c38947e1e382965bc1283 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 21 Aug 2026 18:15:18 +0800 Subject: [PATCH] fix(web): stop rectification effects from setting state on mount eslint-config-next failed staging lint because the case snapshot helper and choice-card reset called setState from useEffect. Load the snapshot in the fetch callback and remount the card by question id instead. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 +++++ .../components/rectification-agentic-chat.tsx | 59 ++++++++++++------- .../components/rectification-choice-card.tsx | 4 -- .../tests/rectification-agentic-entry.test.ts | 8 +++ 4 files changed, 62 insertions(+), 25 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d0d2592f..42164b31 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5179,3 +5179,19 @@ - 复发自:BUG-308(源码合同锚点过期把无关提交打红);BUG-341(第三条咨询路径与草稿变量未改合同) - 修复版本:6a0394aa +## BUG-343 | 生时纠正点选卡 effect 同步 setState,staging lint 失败 + +- 状态:resolved +- 首次发现:2026-08-21 +- 最近更新:2026-08-21 +- 影响面:Gitea `backend-quality-gate` `validate`、`rectification-agentic-chat` 案例快照加载、`rectification-choice-card` 换题重置 +- 用户现象:BUG-342 合同修过后 `npm test` 1871/1871 通过,但 `npm run lint` 因 `react-hooks/set-state-in-effect` 失败。run `2028` `validate` 9m39s 失败,`publish` 1 秒跳过,站点仍停在 `e7f4030e`。 +- 触发条件:`npm run lint --prefix frontend`。挂载校时会话或换一道 A/B/C/D 题。 +- 根因:挂载时 `useEffect` 直接调用含 `setState` 的 `loadCaseSnapshot()`;点选卡用 effect 在 `question_id` 变化时 `setSelectedKey("")`。`eslint-config-next` 禁止 effect 同步 setState。同类于 BUG-339。 +- 修复:案例快照改为 `fetch().then` 回调里应用 payload(事件处理仍走 `loadCaseSnapshot`)。点选卡用 `key={question_id}` 换题重挂,不再在 effect 里清选中态。 +- 验证:`npm run lint --prefix frontend` 0 error;`frontend/tests/rectification-agentic-entry.test.ts`。 +- 防复发:校时 UI 不得在 effect 里直接 `setState` 或调用会 `setState` 的 helper。换题重置必须用 `key` 重挂。合同禁止 `void loadCaseSnapshot()` 和 `setSelectedKey("")`。 +- 相关记录:BUG-339、BUG-342 +- 复发自:BUG-339(盘面入口 effect 同步 setState;点选卡与快照加载又写了同一模式) +- 修复版本:pending + diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 13ba6aa0..849f78c6 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -306,6 +306,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { // Candidate snapshot comes from the persisted Candidate Snapshot API, never // from parsing agent text or hidden sentinels. + const applyCaseSnapshot = useCallback((payload: { + latest_result?: unknown; + choice_card?: unknown; + case?: { accepted_time?: unknown; confirmed_time?: unknown }; + } | null) => { + if (!payload) return; + const nextCandidate = parseRectificationCandidateResult(payload.latest_result); + const nextChoice = parseRectificationChoiceCard(payload.choice_card); + const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null; + const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; + setCandidateResult(nextCandidate); + setChoiceCard(nextChoice); + if (confirmedTime) { + setSavedTime(confirmedTime); + setSavedStatus("confirmed"); + } else if (acceptedTime) { + setSavedTime(acceptedTime); + setSavedStatus("accepted"); + } + }, []); + const loadCaseSnapshot = useCallback(async () => { try { const response = await fetch( @@ -313,32 +334,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { { cache: "no-store" }, ); if (!response.ok) return; - const payload = await response.json().catch(() => null); - const nextCandidate = parseRectificationCandidateResult(payload?.latest_result); - const nextChoice = parseRectificationChoiceCard(payload?.choice_card); - const acceptedTime = typeof payload?.case?.accepted_time === "string" ? payload.case.accepted_time : null; - const confirmedTime = typeof payload?.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; - setCandidateResult(nextCandidate); - setChoiceCard(nextChoice); - if (confirmedTime) { - setSavedTime(confirmedTime); - setSavedStatus("confirmed"); - } else if (acceptedTime) { - setSavedTime(acceptedTime); - setSavedStatus("accepted"); - } + applyCaseSnapshot(await response.json().catch(() => null)); } catch { // Snapshot refresh is best-effort; the durable Case remains on the server. } - }, [caseId, sessionId]); + }, [applyCaseSnapshot, caseId, sessionId]); useEffect(() => { - let active = true; - void loadCaseSnapshot().then(() => { - if (!active) return; - }); - return () => { active = false; }; - }, [loadCaseSnapshot]); + const controller = new AbortController(); + void fetch( + `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, + { cache: "no-store", signal: controller.signal }, + ) + .then((response) => (response.ok ? response.json() : null)) + .then((payload) => { + if (!controller.signal.aborted) applyCaseSnapshot(payload); + }) + .catch(() => { + // Snapshot refresh is best-effort; the durable Case remains on the server. + }); + return () => controller.abort(); + }, [applyCaseSnapshot, caseId, sessionId]); const send = useCallback(async (action: "opening" | "message", messageText: string) => { const trimmed = action === "message" ? messageText.trim() : ""; @@ -839,6 +855,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { )} {showChoiceCards && message.renderKey === choiceCardMessageKey && choiceCard && ( option.role === "primary"); const secondary = props.card.options.filter((option) => option.role === "secondary"); - useEffect(() => { - setSelectedKey(""); - }, [props.card.question_id]); - useEffect(() => { if (!props.pending) firstChoiceRef.current?.focus(); }, [props.card.question_id, props.pending]); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 712b5b6d..0f4647e2 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -44,6 +44,10 @@ const completedActivityReceipt = readFileSync( new URL("../src/components/completed-activity-receipt.tsx", import.meta.url), "utf8", ); +const choiceCardComponent = readFileSync( + new URL("../src/components/rectification-choice-card.tsx", import.meta.url), + "utf8", +); const caseRoute = readFileSync( new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8", @@ -379,6 +383,8 @@ test("the natal house table is a live board beside the chat, not a message", () assert.match(chat, /className="conversation is-rectification"/); assert.match(chat, /