fix redirect race condition

Coordinate 401 redirect logic between ApiProvider and ProtectedRoute using a shared flag to prevent multiple simultaneous redirects that caused UI flashing. Ensure both auth error paths check and set the redirect flag before navigating to login, eliminating race conditions where both mechanisms could trigger at once
This commit is contained in:
Josh Hawkins 2025-12-04 12:06:39 -06:00
parent bbe18d5d6b
commit 0902e4bf7e
3 changed files with 37 additions and 8 deletions

View File

@ -0,0 +1,12 @@
// Module-level flag to prevent multiple simultaneous redirects
// (eg, when multiple SWR queries fail with 401 at once, or when
// both ApiProvider and ProtectedRoute try to redirect)
let _isRedirectingToLogin = false;
export function isRedirectingToLogin(): boolean {
return _isRedirectingToLogin;
}
export function setRedirectingToLogin(value: boolean): void {
_isRedirectingToLogin = value;
}

View File

@ -3,13 +3,10 @@ import { SWRConfig } from "swr";
import { WsProvider } from "./ws"; import { WsProvider } from "./ws";
import axios from "axios"; import axios from "axios";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect";
axios.defaults.baseURL = `${baseUrl}api/`; axios.defaults.baseURL = `${baseUrl}api/`;
// Module-level flag to prevent multiple simultaneous redirects
// (eg, when multiple SWR queries fail with 401 at once)
let isRedirectingToLogin = false;
type ApiProviderType = { type ApiProviderType = {
children?: ReactNode; children?: ReactNode;
options?: Record<string, unknown>; options?: Record<string, unknown>;
@ -35,8 +32,8 @@ export function ApiProvider({ children, options }: ApiProviderType) {
) { ) {
// redirect to the login page if not already there // redirect to the login page if not already there
const loginPage = error.response.headers.get("location") ?? "login"; const loginPage = error.response.headers.get("location") ?? "login";
if (window.location.href !== loginPage && !isRedirectingToLogin) { if (window.location.href !== loginPage && !isRedirectingToLogin()) {
isRedirectingToLogin = true; setRedirectingToLogin(true);
window.location.href = loginPage; window.location.href = loginPage;
} }
} }

View File

@ -1,7 +1,11 @@
import { useContext } from "react"; import { useContext, useEffect } from "react";
import { Navigate, Outlet } from "react-router-dom"; import { Navigate, Outlet } from "react-router-dom";
import { AuthContext } from "@/context/auth-context"; import { AuthContext } from "@/context/auth-context";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import {
isRedirectingToLogin,
setRedirectingToLogin,
} from "@/api/auth-redirect";
export default function ProtectedRoute({ export default function ProtectedRoute({
requiredRoles, requiredRoles,
@ -10,6 +14,20 @@ export default function ProtectedRoute({
}) { }) {
const { auth } = useContext(AuthContext); const { auth } = useContext(AuthContext);
// Redirect to login page when not authenticated
// don't use <Navigate> because we need a full page load to reset state
useEffect(() => {
if (
!auth.isLoading &&
auth.isAuthenticated &&
!auth.user &&
!isRedirectingToLogin()
) {
setRedirectingToLogin(true);
window.location.href = "/login";
}
}, [auth.isLoading, auth.isAuthenticated, auth.user]);
if (auth.isLoading) { if (auth.isLoading) {
return ( return (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> <ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
@ -23,7 +41,9 @@ export default function ProtectedRoute({
// Authenticated mode (8971): require login // Authenticated mode (8971): require login
if (!auth.user) { if (!auth.user) {
return <Navigate to="/login" replace />; return (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
);
} }
// If role is null (shouldnt happen if isAuthenticated, but type safety), fallback // If role is null (shouldnt happen if isAuthenticated, but type safety), fallback