Compare commits
2 Commits
85c6669a70
...
ecb72cc5de
| Author | SHA1 | Date | |
|---|---|---|---|
| ecb72cc5de | |||
| a4ab229e84 |
@@ -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;点选卡与快照加载又写了同一模式)
|
||||
- 修复版本:a4ab229e
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<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/);
|
||||
|
||||
Reference in New Issue
Block a user