跳至主要内容

ADR-009: 情境式返回與 Return Boundary

狀態

已接受 (2026-08-17)

背景 (Context)

問題陳述

深入頁面(Detail、Editor、Viewer)可以從多個工作脈絡進入:列表、工作台、儀表板、 其他資源的關聯區塊、或直接貼網址。但頁面的「返回」實作只有三種常見寫法,三種都會壞:

  1. 寫死返回 URLnavigate('/orders'))——從工作台進來的使用者被送到訂單列表, 離開了原本的工作脈絡與其列表狀態。
  2. 自行傳遞來源?from=sales?returnTo=…location.state)——每個入口都要記得傳, 新增入口時必漏;參數可被使用者改動,重新整理與貼網址後也不再可信。
  3. 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條件行為
1close目前是 openDetachedWindow() 開出的獨立視窗關閉視窗
2history存在應用內上一頁navigate(-1)
3boundary最近的 <ReturnBoundary> 落點且不等於目前頁面navigate(path, { replace: true })
4fallback以上皆否navigate(fallback, { replace: true }),預設 /

2. 應用內上一頁的判定

以 React Router 寫在 window.history.state.idx 的 entry 索引判定(> 0 即有上一頁)。 此判定同時涵蓋四種進入來源:應用內導航(history)、重新整理(保留 entry stack,仍為 history)、直接連結與新分頁(idx === 0boundary)。

不採 document.referrer(跨文件導航才有值)與 useNavigationType()(分不出「應用內上一頁」 與「本分頁第一個 entry」)。

3. 邊界以 Context 宣告,不用 route handle

<ReturnBoundary path label> 經 React Context 提供,useReturnAction() 取最近者。 參考實作由 AppletShell 以既有的 basePath 宣告,applet 內零改動。

4. 返回一律不留 entry

boundaryfallback 使用 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「深入頁面返回紀律」— 應用端使用規範