ADR-009: 情境式返回與 Return Boundary
狀態
已接受 (2026-08-17)
背景 (Context)
問題陳述
深入頁面(Detail、Editor、Viewer)可以從多個工作脈絡進入:列表、工作台、儀表板、 其他資源的關聯區塊、或直接貼網址。但頁面的「返回」實作只有三種常見寫法,三種都會壞:
- 寫死返回 URL(
navigate('/orders'))——從工作台進來的使用者被送到訂單列表, 離開了原本的工作脈絡與其列表狀態。 - 自行傳遞來源(
?from=sales、?returnTo=…、location.state)——每個入口都要記得傳, 新增入口時必漏;參數可被使用者改動,重新整理與貼網址後也不再可信。 - 裸
navigate(-1)——直接連結或新分頁進入時(本分頁沒有上一頁)會把使用者送出應用。
參考實作三種寫法同時存在,且「同一個編輯頁的取消與錯誤返回落點不同」已可觀察到。
限制條件
- Applet 內部路由以宣告式
<Routes>組成(見 ADR-006 AppletShell),只有 applet 掛載點 在createBrowserRouter的 route objects 上。 - 框架不得要求應用改寫既有的 applet 路由組成方式。
- 「返回後恢復列表狀態」的既有機制是 finder slice(Redux),不在 URL;本決策不改變它。
react-router已是@appfuse/appfuse-web的 peerDependency。
關鍵利害關係人
- 使用框架的前端團隊(提案來源)
- 框架維護者
- UX 設計
決策 (Decision)
選擇的方案
由框架提供情境式返回能力(@appfuse/appfuse-web/routing),頁面只表達「我要返回」,
落點由框架依目前情境解析;由 collection/applet root 宣告 <ReturnBoundary> 作為兜底落點。
實施細節
1. 解析順序
| 順序 | kind | 條件 | 行為 |
|---|---|---|---|
| 1 | close | 目前是 openDetachedWindow() 開出的獨立視窗 | 關閉視窗 |
| 2 | history | 存在應用內上一頁 | navigate(-1) |
| 3 | boundary | 最近的 <ReturnBoundary> 落點且不等於目前頁面 | navigate(path, { replace: true }) |
| 4 | fallback | 以上皆否 | navigate(fallback, { replace: true }),預設 / |
2. 應用內上一頁的判定
以 React Router 寫在 window.history.state.idx 的 entry 索引判定(> 0 即有上一頁)。
此判定同時涵蓋四種進入來源:應用內導航(history)、重新整理(保留 entry stack,仍為
history)、直接連結與新分頁(idx === 0 → boundary)。
不採 document.referrer(跨文件導航才有值)與 useNavigationType()(分不出「應用內上一頁」
與「本分頁第一個 entry」)。
3. 邊界以 Context 宣告,不用 route handle
<ReturnBoundary path label> 經 React Context 提供,useReturnAction() 取最近者。
參考實作由 AppletShell 以既有的 basePath 宣告,applet 內零改動。
4. 返回一律不留 entry
boundary 與 fallback 使用 replace。否則瀏覽器上一頁會把使用者送回剛離開的深入頁面。
5. 獨立視窗關閉是 opt-in
瀏覽器只允許 script 開啟的視窗被關閉,故只有經 openDetachedWindow() 開啟並標記的視窗,
返回才走關閉分支;關閉被阻擋時自動退回一般解析。
6. 未儲存內容由單一機制攔截
useUnsavedChangesGuard() 同時涵蓋應用內導航(React Router blocker)與整頁離開
(beforeunload),使返回鈕、取消鈕、側邊導航與重新整理走同一道確認。
7. 明確結果導航不受此規則約束
建立成功前往新記錄、編輯成功回到該筆詳情、刪除後離開已不存在的資源,都是「操作的結果」
而非「回到原本在做的事」,仍由頁面顯式導航,但一律 replace。
考慮的替代方案 (Alternatives Considered)
方案 A: 以 route handle + useMatches() 解析邊界
描述:在 data router 的 route objects 上宣告 handle: { returnBoundary: true },
以 useMatches() 由下而上找最近的邊界。
優點: 與 React Router 的資料路由慣例一致;邊界與路由定義同處。
缺點: useMatches() 只看得到 data router 的 route objects。Applet 內部是宣告式
<Routes>(ADR-006),new/:id/:id/edit 全不在其中,解析必然落空;要修正就得
把所有 applet 改寫成 route objects。
為何未採用: 需要改寫既有 applet 路由架構,代價與收益不成比例;Context 方案同時支援 兩種路由組成方式。
方案 B: 各頁面依 location.state 帶來源
描述:導航進入深入頁面時以 state: { from } 傳來源,返回時讀取。
優點: 不需框架支援。
缺點: 每個入口都要記得傳;重新整理後 state 雖保留但直接連結/新分頁沒有; 新增入口時漏傳是靜默失敗(返回落到錯的地方,不報錯)。
為何未採用: 與 ?from= 同一類問題,只是換個載體;責任仍散在每個呼叫端。
方案 C: 全部改用 navigate(-1),直接連結時接受離開應用
描述:不做邊界,一律裸退。
優點: 零機制。
缺點: 直接連結與新分頁(分享網址、書籤、從郵件點進來)會把使用者送出應用。
為何未採用: 這正是提案要修的其中一個病。
決策後果 (Consequences)
正面影響
- ✅ 頁面不再需要知道自己從哪來,新增入口不必改深入頁面。
- ✅ 四種進入來源(應用內、重新整理、直接連結、新分頁)行為明確且可測。
- ✅ 返回、取消、捨棄共用同一條路徑,落點不再各自漂移。
- ✅ 邊界宣告集中在 applet root,設計語言只需承諾使用者可感知的結果。
負面影響
- ⚠️
useUnsavedChangesGuard()依賴 data router(useBlocker);非 data router 的應用 只能使用返回能力、無法使用攔截。已於 README 註明。 - ⚠️ 「返回後恢復列表狀態」仍倚賴 finder slice;狀態若只存在元件本地 state,返回後不會恢復。 本決策不解決該類頁面,需個別把狀態提升到 slice 或 URL。
風險
- 🚨
window.history.state.idx是 React Router 的實作細節,未來版本若更名會使判定退化為 「無應用內上一頁」(機率: 低,影響: 中)。緩解:判定集中在hasInAppHistory()單一函式, 且有單元測試覆蓋;退化行為是落到邊界(安全方向),不會把使用者送出應用。
參考資料 (References)
appfuse-web/lib/routing/README.md— API 與解析順序- ADR-006: AppletShell 統一外殼架構
- UX 指南 §12「深入頁面的返回」— 使用者可感知的結果
- 前端模組 practices
m-web-common.md「深入頁面返回紀律」— 應用端使用規範