diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ce37579d..64c01043 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -9631,7 +9631,7 @@ - 根因:`showSelectionCards` 用 `!busy` 整张卸卡;采用请求把对话标成 busy。收尾句用未定义样式的 `.rectification-pending-note` 直接挂在消息列表里,没有助手列让位。采用后若 live offer 锚点被清掉,卡也不再回到原消息。 - 修复:采用中或已采用时 busy 不再卸卡;用 resultId 锁住首次出示卡片的那条消息。收尾句改到卡片下方,样式与卡片同一条左边线。已采用列按钮保持按下并禁用。不恢复输入框上方状态条。 - 验证:`frontend/tests/rectification-adopt-cards-stay-20260909.test.ts`、`frontend/tests/rectification-candidate-offer-anchor.test.ts`。`rectification-surface-contract.test.ts` 锁 `verified_idle` 为 `verifiedIdleCopy && !showSelectionCards`(BUG-622)。 -- 防复发:交付卡不得因采用 busy 卸载;`verified_idle` 不得使用无 inset 的裸段落;不得把采用状态写回 composer。改 `verified_idle` JSX 条件时必须同步表面合同正则。 +- 防复发:交付卡不得因采用 busy 卸载;`verified_idle` 不得使用无 inset 的裸段落;不得把采用状态写回 composer。改 `verified_idle` JSX 条件时必须同步表面合同正则。卡锚锁必须走渲染期 `useState`,不得在 render 里读写 ref(`react-hooks/refs`)。 - 相关记录:BUG-595、BUG-619、BUG-622 - 复发自:无 - 修复版本:待发布 @@ -9644,10 +9644,10 @@ - 影响面:Gitea Independent Staging Quality Gate(`npm test --prefix frontend`)、`class-name-definition-contract.test.ts`、`rectification-surface-contract.test.ts` - 用户现象:staging 推送后质量门失败,镜像不发布。产品 UI 无新故障。 - 触发条件:`ca921c3f`(BUG-618–620)改了 `verified_idle` JSX;`5c476ba7`(BUG-614/615)给 `.personal-report-chart-cell.is-changed` 写了填充。随后 `af70ae77` 再推 staging 仍是同一对失败(runs 2514 / 2516 / 2517)。 -- 根因:产品代码已改,合同测试仍锁旧字面量。`knownUnstyled` 还列着已有 CSS 规则的 `is-changed`;表面合同仍要求 `questionGap === "verified_idle" && (`,实际已是 `verifiedIdleCopy && !showSelectionCards`。 -- 修复:`is-changed` 移出 allowlist;`verified_idle` 断言改跟现行 JSX。不改业务组件。 -- 验证:`frontend/tests/class-name-definition-contract.test.ts`、`frontend/tests/rectification-surface-contract.test.ts`。 -- 防复发:给已在 allowlist 的 class 加规则必须同时删 allowlist 行;改 `verified_idle` 条件必须改表面合同正则,并写原值/新值/原因。 +- 根因:产品代码已改,合同测试仍锁旧字面量。`knownUnstyled` 还列着已有 CSS 规则的 `is-changed`;表面合同仍要求 `questionGap === "verified_idle" && (`,实际已是 `verifiedIdleCopy && !showSelectionCards`。测试转绿后 lint 才跑到:BUG-620 在渲染期读写 `selectionOfferLockRef`,`react-hooks/refs` 4 error。 +- 修复:`is-changed` 移出 allowlist;`verified_idle` 断言改跟现行 JSX。采用卡锁改为 `useState`,在渲染期按 `seededTurns` 同一模式更新(`nextSelectionCardLock` 同值返回同一对象,避免循环 setState)。 +- 验证:`frontend/tests/class-name-definition-contract.test.ts`、`frontend/tests/rectification-surface-contract.test.ts`、`frontend/tests/rectification-adopt-cards-stay-20260909.test.ts`、`frontend/tests/rectification-candidate-offer-anchor.test.ts`;`npm run lint` 0 error。 +- 防复发:给已在 allowlist 的 class 加规则必须同时删 allowlist 行;改 `verified_idle` 条件必须改表面合同正则,并写原值/新值/原因。跨渲染保留的卡锚不得写进 ref 再在 render 里读。 - 相关记录:BUG-614、BUG-615、BUG-620 - 复发自:无 - 修复版本:待发布 diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 47245119..5038ba43 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -519,7 +519,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const stepStartedAtRef = useRef(0); const liveToolRef = useRef(null); const liveBaseLabelRef = useRef("正在处理…"); - const selectionOfferLockRef = useRef(null); + const [selectionOfferLock, setSelectionOfferLock] = useState(null); const [compactBoard, setCompactBoard] = useState(false); const [boardOpen, setBoardOpen] = useState(false); const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null)); @@ -1458,14 +1458,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey; const liveSelectionCardKey = persistedOfferKey ?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined); - selectionOfferLockRef.current = nextSelectionCardLock(selectionOfferLockRef.current, { + // Same render-time store update as seededTurns: the lock must be current + // before this paint (BUG-620). A ref write here trips react-hooks/refs. + const nextOfferLock = nextSelectionCardLock(selectionOfferLock, { resultId: candidateResult?.resultId, key: liveSelectionCardKey, }); + if (nextOfferLock !== selectionOfferLock) { + setSelectionOfferLock(nextOfferLock); + } const selectionCardMessageKey = resolveSelectionCardMessageKey({ persistedOfferKey, fallbackKey: latestSettledAssistant?.renderKey, - locked: selectionOfferLockRef.current, + locked: nextOfferLock, resultId: candidateResult?.resultId, selectedTime: candidateResult?.selectedTime, canOffer: canOfferCards, diff --git a/frontend/src/lib/rectification-agentic/v9/turn-question.ts b/frontend/src/lib/rectification-agentic/v9/turn-question.ts index 1c83666e..b4e8802b 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-question.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-question.ts @@ -203,7 +203,10 @@ export function nextSelectionCardLock( input: { resultId: string | null | undefined; key: string | undefined }, ): SelectionCardLock | null { if (!input.resultId) return null; - if (input.key) return { resultId: input.resultId, key: input.key }; + if (input.key) { + if (current?.resultId === input.resultId && current.key === input.key) return current; + return { resultId: input.resultId, key: input.key }; + } if (current?.resultId === input.resultId) return current; return null; } diff --git a/frontend/tests/rectification-adopt-cards-stay-20260909.test.ts b/frontend/tests/rectification-adopt-cards-stay-20260909.test.ts index e4592ba2..8f174d37 100644 --- a/frontend/tests/rectification-adopt-cards-stay-20260909.test.ts +++ b/frontend/tests/rectification-adopt-cards-stay-20260909.test.ts @@ -28,7 +28,12 @@ test("clicking 更像这个 does not unmount the range card for busy", () => { test("after adopt the card stays on the locked offering message", () => { assert.match(chat, /resolveSelectionCardMessageKey/); assert.match(chat, /nextSelectionCardLock/); - assert.match(chat, /selectionOfferLockRef/); + // 原值:selectionOfferLockRef.current = nextSelectionCardLock(...) + // 新值:useState + 渲染期 setSelectionOfferLock(与 seededTurns 同一模式) + // 原因:react-hooks/refs 禁止渲染期读写 ref;门禁 lint 0 error(BUG-622) + assert.match(chat, /const \[selectionOfferLock, setSelectionOfferLock\] = useState/); + assert.match(chat, /if \(nextOfferLock !== selectionOfferLock\) \{\s*setSelectionOfferLock\(nextOfferLock\);/); + assert.doesNotMatch(chat, /selectionOfferLockRef/); assert.match(messageLoop, /showSelectionCards && candidateResult && message\.renderKey === selectionCardMessageKey/); }); diff --git a/frontend/tests/rectification-candidate-offer-anchor.test.ts b/frontend/tests/rectification-candidate-offer-anchor.test.ts index b8b49163..af8ee7c3 100644 --- a/frontend/tests/rectification-candidate-offer-anchor.test.ts +++ b/frontend/tests/rectification-candidate-offer-anchor.test.ts @@ -137,6 +137,10 @@ test("hydrated turns drop a stale client offer when GET says this turn is not th test("the offer lock keeps the original card host after adopt clears the live key", () => { const locked = nextSelectionCardLock(null, { resultId: "result-1", key: "offer" }); assert.deepEqual(locked, { resultId: "result-1", key: "offer" }); + assert.equal( + nextSelectionCardLock(locked, { resultId: "result-1", key: "offer" }), + locked, + ); assert.deepEqual( nextSelectionCardLock(locked, { resultId: "result-1", key: undefined }), locked,