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(AllocationDetailDrawerBulkAllocationDrawerAllocationSettingFormDrawer)在 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.onSaveAllocationSettingForm.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。

Sources

  • GitLab MR !742 fix(allocations): guard cross-project productId on submit
  • Backend MR !463 assertProductsBelongToProject