Supabase getSession() 在 onAuthStateChange 回調內造成死鎖 — 排查與修復全記錄

部署 SPA 後執行功能卡住、F5 才能恢復?問題根源是 Supabase JS v2 的 getSession() 鎖機制,在 onAuthStateChange 回調內呼叫會造成死鎖。本文記錄完整排查過程與修復方案。

症狀

React + FastAPI + Supabase 的 SPA 專案部署後,出現一個詭異的問題:

  • 登入後進入 Dashboard,點「執行」按鈕會卡住(永遠顯示「提交中…」)
  • 按 F5 重新整理後,馬上再點就正常
  • 過一段時間不操作,再點又卡住

這個問題在開發模式(Vite dev server + proxy)完全不會出現,只有 production 部署才會觸發。

錯誤排查過程

第一步:懷疑靜態檔案 Cache(錯誤方向)

一開始以為是 FastAPI 的 catch-all 路由沒設 Cache-Control,導致瀏覽器快取了舊的 index.html

# backend/main.py — SPA catch-all
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str):
    file_path = FRONTEND_DIST / full_path
    if file_path.exists() and file_path.is_file():
        return FileResponse(file_path)
    return FileResponse(
        FRONTEND_DIST / "index.html",
        headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
    )
Code language: PHP (php)

加了 Cache-Control 後問題依舊。這個改動本身是好習慣,但不是根因。

第二步:懷疑 OAuth Callback 競態(部分正確)

發現 initialize()getSession() 取 session,但 OAuth callback 的 hash fragment 還沒被 Supabase 解析,導致 session = null → 直接跳轉 /login

修了 CallbackPage 加入 authReady 狀態等待 SIGNED_IN 事件,登入流程確實修好了,但「執行卡住」的問題仍然存在。

第三步:找到真正原因 — getSession() 死鎖

關鍵線索:F5 後馬上操作就正常,等一段時間又卡住

這代表問題與時間有關。Supabase JWT token 預設 1 小時過期,到期後 Supabase 自動觸發 TOKEN_REFRESHED 事件。而我們的 onAuthStateChange 回調裡呼叫了 checkUserStatus(),形成了一條死鎖鏈:

onAuthStateChange 回調執行(持有內部鎖)
  → checkUserStatus()
    → fetchWithAuth()
      → getAccessToken()
        → supabase.auth.getSession()  ← 需要同一把鎖,死鎖!
Code language: CSS (css)

Supabase JS v2(v2.39+ 起)的 getSession() 使用內部鎖機制。onAuthStateChange 回調內呼叫 getSession() 會嘗試取得已被回調持有的鎖,造成永久等待。

一旦死鎖發生,所有後續的 getSession() 呼叫都會被阻塞,導致任何需要認證的 API 呼叫都卡住。F5 重新載入頁面後,鎖被釋放,一切恢復正常 — 直到下次 token 過期。

解決方案

核心思路:永遠不在 onAuthStateChange 回調內呼叫 getSession()

1. 用 module-level 變數快取 token

// lib/supabase.ts
let _cachedAccessToken: string | null = null

export function setAccessToken(token: string | null) {
  _cachedAccessToken = token
}

// 同步讀取,不呼叫 getSession()
export function getAccessToken(): string | null {
  return _cachedAccessToken
}

export async function refreshAccessToken(): Promise<string | null> {
  const { data: { session }, error } = await supabase.auth.refreshSession()
  if (error) throw error
  const token = session?.access_token ?? null
  _cachedAccessToken = token
  return token
}
Code language: JavaScript (javascript)

2. 在 onAuthStateChange 中同步更新快取

// stores/authStore.ts
supabase.auth.onAuthStateChange(async (event, session) => {
  // 同步更新快取 token — 不呼叫 getSession()
  setAccessToken(session?.access_token ?? null)
  set({ session, user: session?.user ?? null })

  // 只在 INITIAL_SESSION 和 SIGNED_IN 時檢查用戶狀態
  // TOKEN_REFRESHED 只需更新 token,不需要重新驗證
  if (session && (event === 'INITIAL_SESSION' || event === 'SIGNED_IN')) {
    await get().checkUserStatus()
  } else if (event === 'SIGNED_OUT') {
    set({ userStatus: null })
  }
})
Code language: JavaScript (javascript)

3. fetchWithAuth 自動使用快取 token

// lib/api.ts — 不需要修改
// getAccessToken() 現在是同步讀取快取,不再呼叫 getSession()
async function fetchWithAuth(endpoint: string, options: RequestInit = {}) {
  let token = getAccessToken()
  if (!token) throw new Error('Not authenticated')

  let response = await fetch(`${API_URL}${endpoint}`, { ... })

  if (response.status === 401) {
    // 401 時才呼叫 refreshAccessToken()(這不在 onAuthStateChange 內,安全)
    token = await refreshAccessToken()
    response = await fetch(`${API_URL}${endpoint}`, { ... })
  }

  return response.json()
}
Code language: JavaScript (javascript)

下次遇到類似問題怎麼查

辨識特徵

如果你的 SPA 出現以下症狀,很可能是 Supabase auth 鎖相關問題:

  1. F5 能修復 — 代表不是後端問題,是前端狀態卡住
  2. 與時間相關 — 剛載入正常,過一段時間壞掉 → 可能是 token refresh 觸發的
  3. API 呼叫 hang 住 — 不是報錯,是永遠沒有回應 → 可能是 Promise 永遠沒 resolve
  4. 只在 production 出現 — dev 模式 HMR 會頻繁重載,不容易觸發 token 過期

除錯步驟

  1. 打開 DevTools Network tab,點執行時觀察:請求有沒有發出去?還是根本沒發?
  2. console.loggetAccessToken() 進入和離開,確認是不是卡在這裡
  3. 檢查 onAuthStateChange 回調裡有沒有呼叫任何 Supabase auth APIgetSessiongetUserrefreshSession
  4. 如果確認是鎖問題,改用 module-level 快取取代 getSession()

怎麼跟 AI 說明這個問題

如果你下次遇到類似問題,想請 AI 幫忙排查,可以這樣描述:

**好的描述**(AI 能快速定位問題):

「我的 React + Supabase 專案部署後,API 呼叫會卡住(不是報錯,是 hang),F5 重新整理後馬上恢復正常,但過一段時間又會卡。開發模式不會出現。我的 onAuthStateChange 回調裡有呼叫需要認證的 API。」

這段描述包含了三個關鍵資訊:

  • 症狀:API hang + F5 修復 + 週期性
  • 環境:production only
  • 可疑操作onAuthStateChange 內呼叫認證 API

**不好的描述**(AI 可能走很多彎路):

「部署後不能用,要 F5 才行」

這太模糊了,AI 會從 Cache-Control、CORS、路由配置等方向猜測,而不是直接看 auth 鎖問題。

提供給 AI 的關鍵檔案

  1. authStore.ts(或你初始化 Supabase auth 的地方)
  2. api.ts(或你做認證 API 呼叫的地方)
  3. supabase.ts(你的 Supabase client 設定)
  4. package.json@supabase/supabase-js 的版本

總結

問題 原因 修法
執行功能卡住、F5 恢復 onAuthStateChange 回調內呼叫 getSession() 造成死鎖 改用 module-level 快取 token
OAuth callback 後卡在驗證中 getSession() 在 hash fragment 解析前回傳 null CallbackPage 等待 SIGNED_IN 事件
index.html 可能被瀏覽器快取 FileResponse 預設無 Cache-Control no-cache, no-store, must-revalidate

核心原則:在 Supabase 的 onAuthStateChange 回調裡,永遠不要呼叫 getSession()getUser() 等需要內部鎖的方法。直接使用回調參數中的 session 物件。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *