From 038e84cae7f56a0a1af8c71b85881ca5d320c6e3 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 27 Apr 2026 22:29:44 +0200 Subject: [PATCH] fix: dedupe parallel admin 401 redirects to /admin/login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visiting /admin/dashboard while logged out caused a navigation storm: the dashboard fires ~7 /api/admin/* queries on mount, each returns 401, each axios interceptor call did `window.location.href = '/admin/login'`. The path-based guard `currentPath.includes('/admin/login')` reads `location.pathname` *synchronously* — but `location.href = …` is async, so all 7 parallel handlers saw the still-old pathname and each fired a fresh navigation. The browser logged 6+ ERR_ABORTED entries and the user saw a flicker storm. Same shape would bite any admin page that fans out queries on mount. Add a module-level `adminLoginRedirectPending` flag set the moment we kick off the first redirect; subsequent 401s in the same tick see it and skip. Single navigation, clean transition to login. Smoke spec 10-admin-redirect-loop locks the regression in by sampling the URL across 5 ticks — if any tick lands somewhere other than /admin/login, the spec fails. --- frontend/src/config/api.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index 3a3a7aa6..d2e609ae 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -11,6 +11,12 @@ import { getApiBaseUrl } from '../utils/url'; // Maintenance mode callback let maintenanceModeCallback: ((enabled: boolean) => void) | null = null; +// Set true the moment we kick off a hard redirect to /admin/login so +// subsequent 401s in the same tick don't queue more navigations on top +// (each `window.location.href = …` aborts the previous, producing a +// flicker storm — see the response interceptor below). +let adminLoginRedirectPending = false; + export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void) => { maintenanceModeCallback = callback; }; @@ -131,10 +137,17 @@ api.interceptors.response.use( // Check if it's an admin route (but not public endpoints) const isAdminRoute = error.config?.url?.includes('/admin') && !error.config?.url?.includes('/public/'); const currentPath = window.location.pathname; - + if (isAdminRoute) { - // Only redirect if we're not already on the admin login page - if (!currentPath.includes('/admin/login')) { + // Only redirect if we're not already on the admin login page. + // `window.location.href = …` is async — `pathname` doesn't change + // synchronously — so a fan-out of 401s (the dashboard fires 7 admin + // queries in parallel) would each see the old pathname and each + // call `location.href`, producing a navigation storm where every + // request is aborted by the next. Guard with a module-level flag + // so only the first 401 triggers the redirect. + if (!currentPath.includes('/admin/login') && !adminLoginRedirectPending) { + adminLoginRedirectPending = true; window.location.href = '/admin/login'; } } else {