fix(rectification): keep the adopt-card lock in state so lint can pass (BUG-622)
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Writing selectionOfferLockRef during render tripped react-hooks/refs after the two contract tests went green. The lock now updates like seededTurns, and nextSelectionCardLock reuses the same object when the key is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-09 19:34:39 +08:00
co-authored by Cursor
parent 3582c3ea99
commit 15cede97e7
5 changed files with 27 additions and 10 deletions
+5 -5
View File
@@ -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-618620)改了 `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
- 复发自:无
- 修复版本:待发布
@@ -519,7 +519,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const stepStartedAtRef = useRef(0);
const liveToolRef = useRef<string | null>(null);
const liveBaseLabelRef = useRef("正在处理…");
const selectionOfferLockRef = useRef<SelectionCardLock | null>(null);
const [selectionOfferLock, setSelectionOfferLock] = useState<SelectionCardLock | null>(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,
@@ -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;
}
@@ -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 errorBUG-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/);
});
@@ -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,