症狀
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 鎖相關問題:
- F5 能修復 — 代表不是後端問題,是前端狀態卡住
- 與時間相關 — 剛載入正常,過一段時間壞掉 → 可能是 token refresh 觸發的
- API 呼叫 hang 住 — 不是報錯,是永遠沒有回應 → 可能是 Promise 永遠沒 resolve
- 只在 production 出現 — dev 模式 HMR 會頻繁重載,不容易觸發 token 過期
除錯步驟
- 打開 DevTools Network tab,點執行時觀察:請求有沒有發出去?還是根本沒發?
- 加
console.log在getAccessToken()進入和離開,確認是不是卡在這裡 - 檢查
onAuthStateChange回調裡有沒有呼叫任何 Supabase auth API(getSession、getUser、refreshSession) - 如果確認是鎖問題,改用 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 的關鍵檔案
authStore.ts(或你初始化 Supabase auth 的地方)api.ts(或你做認證 API 呼叫的地方)supabase.ts(你的 Supabase client 設定)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 物件。