From 81ef53b398dd12e934eb4f0e59b49f3d7ef45998 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 5 Feb 2026 22:14:48 +0800 Subject: [PATCH] fix: prevent 401 errors on window focus when token expires Fixes #5589 When the page returns from background to foreground after the JWT token expires (~15 min), React Query's automatic refetch-on-focus triggers multiple API calls simultaneously. These all fail with 401 Unauthorized, leaving the user with empty content. Solution: - Add useTokenRefreshOnFocus hook that listens to visibilitychange - Proactively refresh token BEFORE React Query refetches - Uses 2-minute buffer to catch expiring tokens early - Graceful error handling - logs error but doesn't block Changes: - Created web/src/hooks/useTokenRefreshOnFocus.ts - Updated isTokenExpired() to accept optional buffer parameter - Exported refreshAccessToken() for use by the hook - Integrated hook into AppInitializer (only when user authenticated) --- web/src/auth-state.ts | 8 ++-- web/src/connect.ts | 2 +- web/src/hooks/useTokenRefreshOnFocus.ts | 51 +++++++++++++++++++++++++ web/src/main.tsx | 9 ++++- 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 web/src/hooks/useTokenRefreshOnFocus.ts diff --git a/web/src/auth-state.ts b/web/src/auth-state.ts index d070bc992..058b96900 100644 --- a/web/src/auth-state.ts +++ b/web/src/auth-state.ts @@ -54,10 +54,12 @@ export const setAccessToken = (token: string | null, expiresAt?: Date): void => } }; -export const isTokenExpired = (): boolean => { +export const isTokenExpired = (bufferMs: number = 30000): boolean => { if (!tokenExpiresAt) return true; - // Consider expired 30 seconds before actual expiry for safety - return new Date() >= new Date(tokenExpiresAt.getTime() - 30000); + // Consider expired with a safety buffer before actual expiry + // Default: 30 seconds for regular requests + // Can use longer buffer (e.g., 2 minutes) for proactive refresh + return new Date() >= new Date(tokenExpiresAt.getTime() - bufferMs); }; export const clearAccessToken = (): void => { diff --git a/web/src/connect.ts b/web/src/connect.ts index 1a8cb7177..622b086b4 100644 --- a/web/src/connect.ts +++ b/web/src/connect.ts @@ -67,7 +67,7 @@ const refreshTransport = createConnectTransport({ const refreshAuthClient = createClient(AuthService, refreshTransport); -async function refreshAccessToken(): Promise { +export async function refreshAccessToken(): Promise { const response = await refreshAuthClient.refreshToken({}); if (!response.accessToken) { diff --git a/web/src/hooks/useTokenRefreshOnFocus.ts b/web/src/hooks/useTokenRefreshOnFocus.ts new file mode 100644 index 000000000..e6f2d406e --- /dev/null +++ b/web/src/hooks/useTokenRefreshOnFocus.ts @@ -0,0 +1,51 @@ +import { useEffect } from "react"; +import { getAccessToken, isTokenExpired } from "@/auth-state"; + +/** + * Hook that proactively refreshes the access token when the tab becomes visible + * and the token is expired or expiring soon. + * + * This prevents React Query's automatic refetch-on-window-focus from triggering + * multiple 401 errors when the user returns to the tab after the token has expired. + * + * Related issue: https://github.com/usememos/memos/issues/5589 + */ +export function useTokenRefreshOnFocus(refreshFn: () => Promise, enabled: boolean = true) { + useEffect(() => { + if (!enabled) return; + + const handleVisibilityChange = async () => { + // Only act when tab becomes visible + if (document.visibilityState !== "visible") { + return; + } + + // Only refresh if we have a token + const token = getAccessToken(); + if (!token) { + return; + } + + // Check if token is expired or expiring soon (within 2 minutes) + // Use a longer buffer than normal requests to be proactive + const bufferMs = 2 * 60 * 1000; // 2 minutes + if (isTokenExpired(bufferMs)) { + try { + console.debug("[useTokenRefreshOnFocus] Token expired/expiring, refreshing before queries refetch"); + await refreshFn(); + console.debug("[useTokenRefreshOnFocus] Token refreshed successfully"); + } catch (error) { + // Don't block - let the normal auth interceptor handle it + // The user will be redirected if refresh fails + console.error("[useTokenRefreshOnFocus] Failed to refresh token:", error); + } + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [refreshFn, enabled]); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index b3599aa43..5d617b210 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -8,9 +8,11 @@ import { RouterProvider } from "react-router-dom"; import "./i18n"; import "./index.css"; import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { refreshAccessToken } from "@/connect"; import { AuthProvider, useAuth } from "@/contexts/AuthContext"; import { InstanceProvider, useInstance } from "@/contexts/InstanceContext"; import { ViewProvider } from "@/contexts/ViewContext"; +import { useTokenRefreshOnFocus } from "@/hooks/useTokenRefreshOnFocus"; import { queryClient } from "@/lib/query-client"; import router from "./router"; import { applyLocaleEarly } from "./utils/i18n"; @@ -24,7 +26,7 @@ applyLocaleEarly(); // Inner component that initializes contexts function AppInitializer({ children }: { children: React.ReactNode }) { - const { isInitialized: authInitialized, initialize: initAuth } = useAuth(); + const { isInitialized: authInitialized, initialize: initAuth, currentUser } = useAuth(); const { isInitialized: instanceInitialized, initialize: initInstance } = useInstance(); const initStartedRef = useRef(false); @@ -39,6 +41,11 @@ function AppInitializer({ children }: { children: React.ReactNode }) { init(); }, [initAuth, initInstance]); + // Proactively refresh token on window focus to prevent 401 errors + // Only enabled when user is authenticated + // Related: https://github.com/usememos/memos/issues/5589 + useTokenRefreshOnFocus(refreshAccessToken, !!currentUser); + if (!authInitialized || !instanceInitialized) { return null; }