Compare commits

...

2 Commits

Author SHA1 Message Date
Jesse_Chen ecb72cc5de docs: record BUG-343 fix SHA
Independent Staging Quality Gate / validate (push) Successful in 12m29s
Independent Staging Quality Gate / publish (push) Failing after 9m9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 18:15:39 +08:00
Jesse_Chen a4ab229e84 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 <cursoragent@cursor.com>
2026-08-21 18:15:18 +08:00
4 changed files with 62 additions and 25 deletions
+16
View File
@@ -5179,3 +5179,19 @@
- 复发自:BUG-308(源码合同锚点过期把无关提交打红);BUG-341(第三条咨询路径与草稿变量未改合同)
- 修复版本:6a0394aa
## BUG-343 | 生时纠正点选卡 effect 同步 setStatestaging 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;点选卡与快照加载又写了同一模式)
- 修复版本:a4ab229e
@@ -306,18 +306,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
// from parsing agent text or hidden sentinels.
const loadCaseSnapshot = useCallback(async () => {
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ 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;
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) {
@@ -327,18 +325,36 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setSavedTime(acceptedTime);
setSavedStatus("accepted");
}
}, []);
const loadCaseSnapshot = useCallback(async () => {
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
);
if (!response.ok) return;
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;
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 () => { active = false; };
}, [loadCaseSnapshot]);
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 && (
<RectificationChoiceCard
key={choiceCard.question_id}
card={choiceCard}
pending={busy}
disabled={readonly}
@@ -20,10 +20,6 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
const primary = props.card.options.filter((option) => 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]);
@@ -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, /<RectificationBoard/);
assert.match(chat, /await loadCaseSnapshot\(\)/);
assert.doesNotMatch(chat, /void loadCaseSnapshot\(\)/);
assert.match(chat, /signal: controller\.signal/);
assert.match(board, /aria-live="polite"/);
assert.match(board, /groupWindowTransitions/);
assert.match(board, /<aside/);
@@ -469,6 +475,8 @@ test("time-selection cards appear under the latest settled agent bubble only aft
);
assert.match(messageLoop, /showSelectionCards && message\.renderKey === selectionCardMessageKey/);
assert.match(messageLoop, /<RectificationChoiceCard/);
assert.match(messageLoop, /key=\{choiceCard\.question_id\}/);
assert.doesNotMatch(choiceCardComponent, /setSelectedKey\(""\)/);
assert.match(chat, /showChoiceCards = Boolean\(/);
assert.match(chat, /choiceCardUserMessage/);
assert.match(chat, /CHOICE_STOP_MESSAGE/);