mirror of https://github.com/usememos/memos.git
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)
This commit is contained in:
parent
86f780d1a4
commit
81ef53b398
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ const refreshTransport = createConnectTransport({
|
|||
|
||||
const refreshAuthClient = createClient(AuthService, refreshTransport);
|
||||
|
||||
async function refreshAccessToken(): Promise<void> {
|
||||
export async function refreshAccessToken(): Promise<void> {
|
||||
const response = await refreshAuthClient.refreshToken({});
|
||||
|
||||
if (!response.accessToken) {
|
||||
|
|
|
|||
|
|
@ -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<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";
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue