跳至主要内容

ADR-010: Dialog / Modal 命名互換與 messaging 呈現層的公開邊界

狀態

已接受 (2026-08-20)

背景 (Context)

問題陳述

框架有兩支模態元件,名字與業界慣例倒置

框架實際角色業界對這個角色的稱呼
Dialogcomponents/feedback/prompt 佇列的呈現面AlertDialog / PromptDialog
Modalcomponents/overlays/通用可組合對話框外殼Dialog

HTML(<dialog>)、WAI-ARIA APG、Headless UI(我們的底層)、Radix、shadcn、MUI 一致 以 Dialog 指稱通用外殼;「modal」在該詞彙裡是形容詞,描述「阻斷其餘介面」這個性質。 兩支元件也都渲染成 role="dialog"

倒置造成的實際代價,有下游證據:

  • 某下游提交改進建議,要求「擴充 Dialog 使其承載結構化 React 內容與自訂操作區」—— 那正是 Modal 既有的能力。建議中的 API 草案使用 onOpenChange,那不是本框架的任何 prop,是 Radix Dialog 的 prop 名:提案者按著 Radix 的 Dialog 心智模型在描述需求。
  • 框架自己的程式碼在跟這個命名打架:modal.tsxdialog.tsx 都必須寫 import { Dialog as HeadlessDialog },為了把 Dialog 這個名字讓給那支具體元件。
  • 參考實作中,以 Modal 封裝的檔案檔名一律叫 *-dialog.tsxcancel-order-dialogdeactivate-dialog…)。寫的人心裡想的是 dialog,手上用的是 Modal

fleet 現況(18 個消費模組,6 條產品線)

項目數量
Dialog(框架 components)直接匯入0
Toast / ToastContainer 直接匯入0
<Modal> 使用129 檔
prompt.*() 呼叫1,665 處
MessageDialog / MessageToast 掛載每模組各 1 次

Dialog 這個名字被一支沒有任何人直接使用的元件佔著,而真正被使用 129 次的那支 叫不到它。

限制條件

  • 框架處於 19.0.0-alpha.*,下游一律 pin 具體版號,升級走 /upgrade-appfuse-web
  • Dialogcontent: string / actions: string[] / count 形狀由 prompt 佇列決定, 不可改為 ReactNode(見「考慮的替代方案 · 方案 C」)。

決策 (Decision)

選擇的方案

互換名字,單一版本一次完成:

現在之後
ModalDialog
DialogPromptDialog
ModalProps / ModalSizeDialogProps / DialogSize

PromptDialog 留在公開匯出面(見下方「決定 B 撤回」)。Toast / ToastContainer 名字與可見性皆不變。

為什麼一次換而非兩步走

名字互換最危險的情況是「舊程式碼照樣編譯過、語意卻變了」。這裡不會發生:

  • Dialog 在 fleet 上零匯入,改名影響 0 個下游檔案。
  • 新舊 Dialog 的 props 幾乎不重疊——舊的必要 content / severity,新的必要 children。 即使有人未修改,也是編譯錯誤而非靜默行為改變。
  • Modal 不再匯出即為編譯錯誤,同樣是響亮的失敗。

兩步走(先 DialogPromptDialog 走完 deprecation cycle,再讓 Modal 接手)的保護 是為了防那個靜默窗口;此處保護沒有標的,只多付一輪 fleet 升級成本。

下游遷移

下游 pin 版號,不會被動受影響,只在升級時遇到,且全部是編譯錯誤。遷移為機械替換:

import { Modal } from '@appfuse/appfuse-web/components' → import { Dialog } from …
<Modal … > → <Dialog … >
ModalProps / ModalSize → DialogProps / DialogSize

注意同檔若另有 import { Dialog } from '@headlessui/react'(手刻外殼的遺留)會撞名—— 那類檔案本就該改用框架元件。


考慮的替代方案 (Alternatives Considered)

方案 A: 不改名,只補文檔

已先行執行(元件總覽加入「Dialog 還是 Modal?」對照表與判斷句、Dialog 的 JSDoc 反向指回 Modal)。不足以單獨解決:fleet 證據顯示,有下游模組在已使用 Modal 五個檔案的情況下,仍自行手刻了六支模態外殼,檔名還寫著 *Modal。名字的引力大過文檔。

保留文檔改善,但不以它取代改名。

方案 B: 把 PromptDialog / Toast / ToastContainer 撤出公開匯出面 —— 已撤回

原提案理由是「它們只是 MessageDialog / MessageToast 手裡的畫筆,公開等於暴露容器的 內部呈現細節」,並以 fleet 零使用為佐證。

撤回理由usePromptSubscription(現 usePromptQueue)本來就是公開匯出,代表 「自建容器、沿用房子呈現」是框架刻意支援的路徑。messaging 因此是一個刻意拆成兩半、 供重組的系統:

prompt API + 狀態(佇列、去重、pending promise)
usePromptQueue 容器邏輯(訂閱、過濾、thread 隔離、移除)
MessageDialog/Toast 現成容器 = 上面兩層 + 呈現
PromptDialog / Toast 呈現

在這個結構下,呈現層是「另一半」而非「洩漏的細節」。撤掉它,自建容器就得連 severity 配色、動作主次降階與左右反轉、count 徽章全部重寫,並必然與房子語言漂移。

fleet 零使用證明的是「目前沒人直接用」,不是「沒人可以用」——對移除而言是必要條件, 不是充分條件。

此次檢視另促成一項改善(見「決策後果」):補上 usePromptQueue 的移除能力與 translatePromptMessage / resolvePromptAction / resolvePromptDismissal, 讓自建容器不必逆向工程 MessageDialog 的內部實作。

方案 C: 擴充 PromptDialog 使其承載 ReactNode(下游原始提案)

否決。 不只是角色不符,是生命週期違規:prompt 是模組級單例(RxJS Subject), 訊息記錄會活過建立它的元件、被 thread 依路由過濾、被去重摺疊成 count。把 JSX 交給 它,等於讓非 React 的單例持有 React 樹,再從另一棵樹渲染——閉包捕獲的 props 早已過期, 而佇列還在等使用者回應。

content: string 不是偷懶,是那條把 React 樹擋在佇列外面的線。需要結構化內容的場景, 答案一律是通用 Dialog(即原 Modal)。


決策後果 (Consequences)

正面影響

  • 從 HTML / ARIA / Headless UI / Radix / shadcn / MUI 任一來源過來的開發者,伸手拿 Dialog 拿到的就是通用外殼。
  • 敘述收斂成兩句話:要對話框 → Dialog;要全域訊息 → prompt() 並掛 MessageDialog。 中間沒有第三個選項可選錯。
  • PromptDialog 的名字自述其角色,不再需要靠 JSDoc 解釋它為什麼只吃字串。
  • PromptDialogFileInput 預覽對話框不再需要 import { Dialog as HeadlessDialog }。 通用 DialogSheet 仍保留該別名——它們的本地元件就叫 Dialog,別名此時反而是 必要的區辨(「這是 Headless UI 的,不是我們的」)。

負面影響

  • 129 個下游檔案需在升級時機械替換。屬 breaking change,須列入 CHANGELOG 的 Breaking Changes 並在 /upgrade-appfuse-web 提供 migration note。
  • 既有文檔、教學與 ADR 中對 Modal 的引用需一併更新。

風險

風險緩解
下游升級時大量編譯錯誤全部是編譯期錯誤、機械可修;下游 pin 版號可自訂升級時機
同檔另有 Headless UI Dialog 匯入而撞名該類檔案本就應改用框架元件;升級指引明列此情況
未來有人重新提議合併兩支本 ADR 的方案 B、C 即為該提議的答覆

參考資料 (References)

  • WAI-ARIA APG: Dialog (Modal)
  • ADR-006: AppletShell 統一外殼架構
  • lib/messaging/prompt-message.ts —— 佇列 ↔ 呈現層的轉譯與回接接縫