Drawer 未隨 context unmount 導致 stale form state 送錯 id
案例:CarbonX Allocation 跨 Project productId 污染,導致排放量算不出來。
問題現象
在 UAT 環境上,customized allocation 的 raw material 階段,allocate_ratio 算出來是 0,導致排放量算不出來。但是手動操作時複現不出來,多位工程師在 local 都試過,都跑不出問題。
真正原因
送出去的 payload 裡面,basis_item.ref_product_id 指向的是「另一個 Project 裡同名的 product」——也就是 Project A 的 productId 被帶到 Project B 去送出。後端拿到一個不屬於當前 Project 的 productId 來算分配比例,自然算出 0。
為什麼 local 複現不出來
關鍵是 stale form state:
- Allocation 相關的 Drawer(
AllocationDetailDrawer、BulkAllocationDrawer、AllocationSettingFormDrawer)在 Project 切換時沒有被 unmount - Drawer 內的 form 還抓著前一個 Project 的 product list
- 使用者切換 Project 後,Drawer 裡 form 的選項看起來沒問題(因為 UI 已經被刷新),但底層 React state 還是舊的
- 這是一個 race condition——必須要在「特定的切換時序」下才會觸發,所以 local 一般操作流程根本撞不到
解法(三層防禦)
1. Frontend submit guard(這次 MR !742 加的)
抽一個 utility:
// src/types/allocation.ts
export const hasCrossProjectProductId = (
submittedIds: string[],
allowedIds: string[]
): boolean => {
const allowedSet = new Set(allowedIds);
if (allowedSet.size === 0) {
return false; // allow-list 還在 loading,跳過檢查讓後端擋
}
return submittedIds.some((id) => !allowedSet.has(id));
};在 AllocationDetailDrawer.onSave 和 AllocationSettingForm.handleSubmit 送 API 前比對 current-project 的 product allow-list(來自 useProjectsAllocationProducts / useProjectsProducts),不對就 toast failedToSave / createFailed 然後 return,PATCH/PUT 根本不會發出去。
2. 用 key={projectId} 強制 remount
在 Drawer 元件加上 key={project?.id}:
<AllocationDetailDrawer key={`detail-${project?.id ?? ''}`} ... />
<BulkAllocationDrawer key={`bulk-${project?.id ?? ''}`} ... />
<AllocationSettingFormDrawer key={projectId} ... />React 看到 key 改變會把整個 component tree unmount 再 mount,form state 跟著重來,徹底消滅 stale state 跨 Project 倖存的可能性。
這跟 Drawer Form 資料不更新的解決方案 是同一個 pattern 的延伸:用 mount/unmount 控制 form lifecycle,不要相信 form 會自動跟著 props 更新。
3. Backend 最終防線(MR !463)
後端 assertProductsBelongToProject 會丟 400。即使前端兩道都失守(例如 allow-list 還在 loading 就跳過了),後端會擋下來。
Key takeaways
- Local 複現不出來 ≠ 不存在:UAT 的 network log 就是證據,不要因為複現不出來就忽略。
- Stale form state 跨 context 切換是 React 隱性 bug 的常見來源:只要 Drawer / Modal / Dialog 裝著 form,又會被多個 context(Project、Tenant、User)共用,就要主動用
key切斷生命週期,不要假設 props 變更會自動同步進去。 - Defense in depth:前端 guard + key remount + 後端 assert,三層各自獨立,任何一層失守都還有救。前端 guard 在 allow-list loading 時主動跳過,把責任交還給後端,這是正確的設計——不要為了「看起來嚴謹」在資料還沒到位時誤擋使用者。
- 同名 entity 跨 scope 是巨大陷阱:Project A 跟 Project B 有同名 product 時,UI 上看起來都對,但 productId 是不同的。任何涉及「使用者選了一個 entity 然後送 id 出去」的流程,都要驗證 id 屬於當前 scope。
Related
- 用 key 強制 remount 切斷 stale state — 這次解法用到的 pattern,抽象成通用版本
- Drawer Form 資料不更新的解決方案 — 同樣用 mount/unmount 解決 form state 不同步
- 分配邏輯(原物料與製造階段) — Allocation 的業務規則背景
Sources
- GitLab MR !742
fix(allocations): guard cross-project productId on submit - Backend MR !463
assertProductsBelongToProject