Commit 81ef53b3 authored by Steven's avatar Steven

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)
parent 86f780d1
...@@ -54,10 +54,12 @@ export const setAccessToken = (token: string | null, expiresAt?: Date): void => ...@@ -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; if (!tokenExpiresAt) return true;
// Consider expired 30 seconds before actual expiry for safety // Consider expired with a safety buffer before actual expiry
return new Date() >= new Date(tokenExpiresAt.getTime() - 30000); // 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 => { export const clearAccessToken = (): void => {
......
...@@ -67,7 +67,7 @@ const refreshTransport = createConnectTransport({ ...@@ -67,7 +67,7 @@ const refreshTransport = createConnectTransport({
const refreshAuthClient = createClient(AuthService, refreshTransport); const refreshAuthClient = createClient(AuthService, refreshTransport);
async function refreshAccessToken(): Promise<void> { export async function refreshAccessToken(): Promise<void> {
const response = await refreshAuthClient.refreshToken({}); const response = await refreshAuthClient.refreshToken({});
if (!response.accessToken) { if (!response.accessToken) {
......
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<void>, 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]);
}
...@@ -8,9 +8,11 @@ import { RouterProvider } from "react-router-dom"; ...@@ -8,9 +8,11 @@ import { RouterProvider } from "react-router-dom";
import "./i18n"; import "./i18n";
import "./index.css"; import "./index.css";
import { ErrorBoundary } from "@/components/ErrorBoundary"; import { ErrorBoundary } from "@/components/ErrorBoundary";
import { refreshAccessToken } from "@/connect";
import { AuthProvider, useAuth } from "@/contexts/AuthContext"; import { AuthProvider, useAuth } from "@/contexts/AuthContext";
import { InstanceProvider, useInstance } from "@/contexts/InstanceContext"; import { InstanceProvider, useInstance } from "@/contexts/InstanceContext";
import { ViewProvider } from "@/contexts/ViewContext"; import { ViewProvider } from "@/contexts/ViewContext";
import { useTokenRefreshOnFocus } from "@/hooks/useTokenRefreshOnFocus";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import router from "./router"; import router from "./router";
import { applyLocaleEarly } from "./utils/i18n"; import { applyLocaleEarly } from "./utils/i18n";
...@@ -24,7 +26,7 @@ applyLocaleEarly(); ...@@ -24,7 +26,7 @@ applyLocaleEarly();
// Inner component that initializes contexts // Inner component that initializes contexts
function AppInitializer({ children }: { children: React.ReactNode }) { 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 { isInitialized: instanceInitialized, initialize: initInstance } = useInstance();
const initStartedRef = useRef(false); const initStartedRef = useRef(false);
...@@ -39,6 +41,11 @@ function AppInitializer({ children }: { children: React.ReactNode }) { ...@@ -39,6 +41,11 @@ function AppInitializer({ children }: { children: React.ReactNode }) {
init(); init();
}, [initAuth, initInstance]); }, [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) { if (!authInitialized || !instanceInitialized) {
return null; return null;
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment