# Reviewer 審查 M8-9 Boot-ID + tab 重連(2026-04-15) ## 摘要 - 結論:**✅ 通過**,無 Critical / Major 問題。 - 是否阻擋 M8-10:**不阻擋**,可直接進入 M8-10。 - 問題統計:Critical 0 / Major 0 / Minor 2 / Suggestion 1 - 親跑:`tsc` PASS、`test` 19/19 PASS、`lint`(改動檔零 error)、`build` 12 頁 PASS。 --- ## A. `checkAndUpdateBootId` action 比對 TDD §9.3 規格(`system-store.ts:92-103`): | 行為 | 實作 | 狀態 | |------|------|------| | `bootId === null` → 記錄 + 回 `'first'` | L94-97 `set({ bootId: newBootId })` → return `'first'` | ✅ | | `bootId === newBootId` → 回 `'match'`,不改 store | L98-100 純 return | ✅ | | `bootId !== newBootId` → 回 `'mismatch'`,**刻意不改 store** | L101-102 註解明確說明用意(reload 後新 tab 重走 first) | ✅ | | TypeScript `BootIdCheckResult` union 定義 | L18-26 `'first' \| 'match' \| 'mismatch'` + JSDoc 完整 | ✅ | 語意正確,註解與 TDD「mismatch 時不更新 store」的理由一致。`get()` 而非閉包 snapshot,避免競態。 ## B. `handleBootIdCheck` helper `use-shutdown-watcher.ts:87-123`: - **封裝完整**:呼叫 store action、處理 loop guard、觸發 reload 全部集中於此 helper。 - **DRY**:`pollOnce`(L146)與 `retryServerHealth`(L347)都走同一 helper,沒有重複邏輯。 - **Response shape 正確**:`BootIdResponse` type(L46-52)完整對應 TDD §9.2 `{ success, data: { bootId, startedAt } }`。L140 / L342 對 `json.success` 與 `json.data?.bootId` 雙重驗證,若缺欄位改走 `recordFailure`,不會把 undefined 丟進比對路徑。 - **回傳語意**:`true` = 繼續後續流程(first / match),`false` = 已觸發 reload(或 SSR / guard skip),呼叫端不再 `markOnline`。L90-92 的 early return 避免對「即將 reload」的 tab 做多餘的 markOnline。 ## C. Reload loop guard `use-shutdown-watcher.ts:87-123`: | 檢查項 | 實作 | 狀態 | |--------|------|------| | key = `visiona-reload-loop-guard` | L38 `const RELOAD_LOOP_GUARD_KEY` | ✅ | | 正常情境:reload → store reset → 走 first 路徑不誤觸發 | L89 `checkAndUpdateBootId` 先於 guard 比對;first 直接 return true,guard 不會被讀到 | ✅ | | 異常情境(server 每次回不同 bootId):guard 命中同一 id → skip reload + `setBootId + markOnline` | L102-111 | ✅ | | sessionStorage 失敗(隱私模式)→ 仍 reload | L100-115 整段包 `try { ... } catch {}`,catch 為空但之後 fall through 到 L117-121 `window.location.reload()` | ✅ | | guard key 寫入時機:**reload 之前** | L112 `setItem` → L121 `reload()`,順序正確 | ✅ | | `typeof window === 'undefined'` → SSR 保險直接 return false | L95-98 | ✅ | **Minor 1**:catch 空 block(L113-115)沒有任何 log,若 sessionStorage 存取失敗(即便在隱私視窗)開發者將看不到訊號。建議加 `console.warn('[boot-id] sessionStorage unavailable, reload guard disabled')`,只是提醒用,不阻擋 reload。不影響行為,不阻擋合併。 ## D. 和 M8-7 的 integration - `pollOnce` (L126-152):保留原流程,新增路徑僅是在 `res.ok + json.success + data.bootId` 全部成立後插入 `handleBootIdCheck`;失敗分支完全沿用 M8-7 的 `recordFailure`,polling → overlay 行為不變。 - WebSocket `server:shutdown-imminent`(L225-249)、restart 10 s defer(L232-242)、visibility change probe(L279-292)、cleanup(L303-323)全部不動。 - Active retry 3 s / normal 10 s 切換邏輯(L186-201)不動,boot-id 比對只發生在「成功取得 response」那條路徑,不影響 retry 節奏。 - `retryServerHealth`(L328-358,M8-7 既有函式)被 patch 成走 `handleBootIdCheck`,對 overlay 的「手動 retry 按鈕」呼叫者透明。 既有測試(M8-7、model-store、lib/api)全數通過 → 零回歸。 ## E. Test 品質 ### Store tests(`system-store.test.ts`)— 4 個 1. `first` call 情境 → 驗 return + `bootId` 被設(L24-28)✅ 2. 相同 bootId → 驗 `'match'` + store 不動(L30-35)✅ 3. 不同 bootId → 驗 `'mismatch'` + store 保留舊值(L37-43)✅ 4. Reset 後再呼叫 → 重新 `'first'`,模擬 reload 後情境(L45-52)✅ TDD §12 要求的三路徑 + 邊界全數覆蓋。beforeEach(L14-22)確實 reset 所有欄位,隔離性 OK。 ### Hook tests(`use-shutdown-watcher.test.ts`)— 5 個 1. 首次 fetch → setBootId + markOnline,無 reload(L65-74)✅ 2. 相同 id fetch → markOnline,無 reload(L76-85)✅ 3. 異 id fetch → `window.location.reload` 被呼叫 + guard 寫入 + store 不更新(L87-99)✅ 4. Loop guard 命中 → 不 reload + store 對齊新 id + `markOnline`(L101-113)✅ 5. Fetch 失敗 → `recordFailure` + 無 reload(L115-124)✅ `mockFetchBootId` helper 乾淨,`vi.stubGlobal('fetch', ...)` 正確。`Object.defineProperty(window, 'location', ...)` 是 jsdom 下替換 `location.reload` 的標準做法(jsdom 的 `window.location` 不可直接賦值)。`afterEach` L61-63 `vi.restoreAllMocks()` 搭配 beforeEach 清除 `sessionStorage` + 重置 store,測試間無耦合。 **Suggestion**:SSR 情境(`typeof window === 'undefined'`)走 false path(L95-98),目前 hook test 不覆蓋。因為 jsdom 下必定有 window,要另寫 node env test。優先級低,M8-9 可接受不補。 **Minor 2**:task brief 提到「sessionStorage 失敗(私密視窗):catch 後仍 reload」的邊界 case,但 hook test 5 個中沒有直接模擬 `sessionStorage.setItem` throw 的情境。實作正確(L100-115 catch fall through 到 reload),但無 regression guard。建議補一個 test:`sessionStorage.setItem` 用 `vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error(); })` → 驗 `reloadSpy` 仍被呼叫。非阻擋性,可列入 M8-10 之後的測試補強清單。 ## F. Build / Test / Lint 結果 | 指令 | 結果 | |------|------| | `pnpm tsc --noEmit` | ✅ 零錯誤(無任何輸出) | | `pnpm test` | ✅ 4 test files / 19 tests 全綠(含本次新增 4+5) | | `pnpm lint`(全 repo) | ⚠️ 16 問題全部為 **pre-existing**(`use-server-health.ts`、`use-websocket.ts` 等檔),**M8-9 改動的 4 個檔零 error / 零 warning** | | `pnpm build` | ✅ Compiled successfully 5.5s / 12 頁 SSG 全數 prerender 完成 | 既有 repo lint 問題不在本次審查範疇,不阻擋 M8-9。 ## G. SSR 相容 - `'use client'` directive 在 L1 正確標記。 - `window`、`sessionStorage`、`window.location.reload` 全部在 `useEffect`(L155)或 `handleBootIdCheck` 函式內使用,且 L95-98 有 `typeof window === 'undefined'` guard(雙重保險,因為 hook 只會在 client 執行但 `retryServerHealth` 可能被 server component 誤呼)。 - `retryServerHealth` 走同一 `handleBootIdCheck`,SSR guard 也保護它。 - Next.js `pnpm build` 的 SSG prerender 12 頁全部成功,證實 build 階段不會觸發 `window is not defined`。 ## H. 完整 flow 驗證(讀 code 推論) ### 場景 1:Restart Server 1. Wails `notifyShutdownImminent(reason=restart)` → WS broadcast。 2. Browser tab 收到(`use-shutdown-watcher.ts:225-249`)→ `restartDeferTimer` 啟動 10 s defer(L234-241),**不立刻** `forceOffline`。 3. 每 10 s(normal)/ 3 s(active-retry)poll `/api/system/boot-id`。Restart 期間 server 約 3 s 起步,fetch 連續 `ECONNREFUSED` → `recordFailure` 累計 → consecutiveFailures ≥ 2 → subscribe callback(L186-201)自動切 active-retry。 4. Server re-spawn → 新 boot-id。 5. 下一次 `pollOnce` → `res.ok + json.success + data.bootId` → `handleBootIdCheck`:store 裡舊 bootId ≠ 新 bootId → mismatch → guard 寫入 → `window.location.reload()`。 6. 新 tab 載入 → Zustand store reset `bootId = null` → 首次 poll → `'first'` → `setBootId` + `markOnline` → 正常運作。 路徑正確 ✅ ### 場景 2:Quit 1. Wails `notifyShutdownImminent(reason=quit)` → WS broadcast。 2. Browser tab 收到 → `mapReason('quit') → 'quit'` → L245 `forceOffline('quit')` 立即執行(**沒有 10 s defer**,defer 只在 `reason === 'restart'` 分支,L232)。 3. Polling 持續失敗(server 整組關掉)→ overlay 顯示「請重開 app」。 4. 每次 poll fail → `recordFailure`,**不進入** `handleBootIdCheck`(整個 L146 block 要 `res.ok + success + data.bootId` 才會進)。 5. Server 沒回來,`reloadSpy` 永不會被呼叫。 路徑正確,不會誤觸發 reload ✅ ### 場景 3:網路 glitch 1. 單次 health check fail → `recordFailure`,consecutiveFailures = 1,尚未 ≥ 2 門檻 → **不** forceOffline、**不** 切模式。 2. 第二次失敗 → consecutiveFailures = 2 → subscribe callback 補上 `forceOffline('healthcheck-failed')`(L191-197)→ 切 active retry。 3. Active retry 3 s 後 → server 還在 → 拿到**同**一 boot-id → `handleBootIdCheck` 回 `'match'`(因為 store.bootId 沒被任何人改)→ L147 的 `if (!shouldContinue)` false → 往下到 L148 `markOnline()`(會同時 reset consecutiveFailures=0 + forcedOffline=false + offlineReason=null)→ subscribe callback 偵測到 shouldActiveRetry=false,下一次 polling tick 自動切回 normal 10 s。 4. `reloadSpy` 不會被呼叫。 路徑正確,不會誤 reload ✅ **額外確認**:Restart 路徑下,如果 boot-id 變化先於 restartDeferTimer 10 s 到期 → `handleBootIdCheck` 會直接 reload,restartDeferTimer 還在 running。`useEffect` cleanup(L312-315)會 clear timer;但 reload 觸發後頁面立刻卸載,timer 會被 jsdom / 瀏覽器自然清掉,無副作用。若 reload 被 SSR / guard skip(極端情境),restartDeferTimer 仍會 10 s 後執行,此時 `forceOffline('restart')` 的 guard 檢查 `!s.serverOnline || consecutiveFailures >= FAILURE_THRESHOLD`(L237)會判定為 false(guard skip 路徑裡 markOnline 已被呼叫)→ 不會誤顯示 overlay。健壯。 ## I. 問題清單 ### Critical 無。 ### Major 無。 ### Minor | # | 檔案 | 行 | 問題 | 建議 | |---|------|----|------|------| | 1 | `frontend/src/hooks/use-shutdown-watcher.ts` | 113-115 | sessionStorage try/catch 吞 error 無 log,異常發生時無法從 console 察覺 | catch 內加 `console.warn('[boot-id] sessionStorage unavailable, reload guard disabled')`,不改 control flow | | 2 | `frontend/src/tests/hooks/use-shutdown-watcher.test.ts` | — | 無 test 覆蓋「sessionStorage.setItem throw → 仍 reload」邊界 | 用 `vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error(); })` 補 1 個 test,驗 `reloadSpy` 仍呼叫 1 次 | ### Suggestion | # | 檔案 | 行 | 建議 | |---|------|----|------| | 1 | `frontend/src/hooks/use-shutdown-watcher.ts` | 95-98 | SSR guard 分支(`typeof window === 'undefined'`)無對應 test;jsdom 下難模擬。可延後到專門的 SSR 測試環境補 | ## J. 結論 M8-9 Boot-ID + tab 自動重連實作完整、流程正確、測試充分。 - Store action `checkAndUpdateBootId` 三路徑語意與 TDD §9.3 一致,`mismatch` 刻意不改 store 的設計與 reload-後-新-tab 重走 first 路徑完美配合。 - Helper `handleBootIdCheck` DRY 封裝 guard + reload,`pollOnce` / `retryServerHealth` 共用同一路徑。 - Reload loop guard(sessionStorage)在正常 / 異常 / 隱私視窗三種情境下都有正確 fall back,不會卡 loop。 - M8-7 既有邏輯(polling 模式切換、WebSocket、visibility、restart defer)全數不變,19 個 tests 全綠零回歸。 - 三個關鍵場景(Restart / Quit / glitch)讀 code 推論路徑全部正確,特別是 Quit 不會誤觸發 reload(`handleBootIdCheck` 進入條件嚴格)、glitch 同 bootId 會乾淨 recover。 - `tsc` / `build` / 改動檔 `lint` 全數 PASS;pre-existing lint 問題不在本次範疇。 **結論:✅ 通過,不阻擋 M8-10**。2 個 Minor 與 1 個 Suggestion 可視為技術債,建議列入 M8-10 之後的測試補強清單處理,非必修。