ADR-010: Dialog / Modal 命名互換與 messaging 呈現層的公開邊界
狀態
已接受 (2026-08-20)
背景 (Context)
問題陳述
框架有兩支模態元件,名字與業界慣例倒置:
| 框架 | 實際角色 | 業界對這個角色的稱呼 |
|---|---|---|
Dialog(components/feedback/) | prompt 佇列的呈現面 | AlertDialog / PromptDialog |
Modal(components/overlays/) | 通用可組合對話框外殼 | Dialog |
HTML(<dialog>)、WAI-ARIA APG、Headless UI(我們的底層)、Radix、shadcn、MUI 一致
以 Dialog 指稱通用外殼;「modal」在該詞彙裡是形容詞,描述「阻斷其餘介面」這個性質。
兩支元件也都渲染成 role="dialog"。
倒置造成的實際代價,有下游證據:
- 某下游提交改進建議,要求「擴充
Dialog使其承載結構化 React 內容與自訂操作區」—— 那正是Modal既有的能力。建議中的 API 草案使用onOpenChange,那不是本框架的任何 prop,是 RadixDialog的 prop 名:提案者按著 Radix 的Dialog心智模型在描述需求。 - 框架自己的程式碼在跟這個命名打架:
modal.tsx與dialog.tsx都必須寫import { Dialog as HeadlessDialog },為了把Dialog這個名字讓給那支具體元件。 - 參考實作中,以
Modal封裝的檔案檔名一律叫*-dialog.tsx(cancel-order-dialog、deactivate-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。 Dialog的content: string/actions: string[]/count形狀由prompt佇列決定, 不可改為 ReactNode(見「考慮的替代方案 · 方案 C」)。
決策 (Decision)
選擇的方案
互換名字,單一版本一次完成:
| 現在 | 之後 |
|---|---|
Modal | Dialog |
Dialog | PromptDialog |
ModalProps / ModalSize | DialogProps / DialogSize |
PromptDialog 留在公開匯出面(見下方「決定 B 撤回」)。Toast / ToastContainer
名字與可見性皆不變。
為什麼一次換而非兩步走
名字互換最危險的情況是「舊程式碼照樣編譯過、語意卻變了」。這裡不會發生:
Dialog在 fleet 上零匯入,改名影響 0 個下游檔案。- 新舊
Dialog的 props 幾乎不重疊——舊的必要content/severity,新的必要children。 即使有人未修改,也是編譯錯誤而非靜默行為改變。 Modal不再匯出即為編譯錯誤,同樣是響亮的失敗。
兩步走(先 Dialog→PromptDialog 走完 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 解釋它為什麼只吃字串。PromptDialog與FileInput預覽對話框不再需要import { Dialog as HeadlessDialog }。 通用Dialog與Sheet仍保留該別名——它們的本地元件就叫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—— 佇列 ↔ 呈現層的轉譯與回接接縫