From 1c26e4cc6c4c065f08600519bebd371af0a850e9 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 13 Mar 2026 10:52:02 +0100 Subject: [PATCH] fix: resolve intermittent auth errors when navigating between tabs Token refresh race condition: when multiple requests got 401 simultaneously, queued retries reused stale headers and the initiator fell through without throwing on retry failure. Now retries regenerate headers (fresh request ID and CSRF token), and both retry failure and refresh failure throw explicitly. SWR cache is now invalidated after token refresh so stale error responses are not served from cache. --- CHANGELOG.md | 1 + lib/api/client.ts | 61 ++++++++++++++++++++++++++++++++------------ lib/auth/context.tsx | 8 ++++-- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 110965b..e8929e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- **No more random errors when switching tabs.** Navigating between Dashboard, Funnels, Uptime, and Settings no longer shows "Invalid credentials", "Something went wrong", or "Site not found" errors. This was caused by a timing issue when your login session refreshed in the background while multiple pages were loading at the same time — all those requests now wait for the refresh to finish and retry cleanly. - **More accurate pageview counts.** Refreshing a page no longer inflates your pageview numbers. The tracking script now detects when the same page is loaded again within a few seconds and skips the duplicate, so metrics like total pageviews, pages per session, and visit duration reflect real navigation instead of reload habits. - **Self-referrals no longer pollute your traffic sources.** Internal navigation within your own site (e.g. clicking from your homepage to your about page) no longer shows your own domain as a referrer. Only external traffic sources appear in your Referrers panel now. - **Screen size fallback now works correctly.** A variable naming issue prevented the fallback screen dimensions from being read when the primary value wasn't available. Screen size data is now reliably captured on all browsers. diff --git a/lib/api/client.ts b/lib/api/client.ts index 9b82f2b..6362b8c 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -244,21 +244,30 @@ async function apiRequest( // * If refresh is already in progress, wait for it to complete (or fail) return new Promise((resolve, reject) => { subscribeToTokenRefresh( - async () => { - try { - const retryResponse = await fetch(url, { - ...options, - headers, - credentials: 'include', - }) - if (retryResponse.ok) { - resolve(await retryResponse.json()) - } else { - reject(new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status)) - } - } catch (e) { - reject(e) + () => { + // * Retry with fresh headers after refresh completes + const retryHeaders: Record = { + 'Content-Type': 'application/json', + [getRequestIdHeader()]: generateRequestId(), } + if (options.headers) { + Object.entries(options.headers as Record).forEach(([key, value]) => { + retryHeaders[key] = value + }) + } + if (isStateChangingMethod(method)) { + const csrfToken = getCSRFToken() + if (csrfToken) retryHeaders['X-CSRF-Token'] = csrfToken + } + fetch(url, { ...options, headers: retryHeaders, credentials: 'include' }) + .then(async (retryResponse) => { + if (retryResponse.ok) { + resolve(await retryResponse.json()) + } else { + reject(new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status)) + } + }) + .catch((e) => reject(e)) }, (err) => reject(err) ) @@ -279,22 +288,40 @@ async function apiRequest( // * Refresh successful, cookies updated onRefreshed() - // * Retry original request + // * Retry original request with fresh headers + const retryHeaders: Record = { + 'Content-Type': 'application/json', + [getRequestIdHeader()]: generateRequestId(), + } + if (options.headers) { + Object.entries(options.headers as Record).forEach(([key, value]) => { + retryHeaders[key] = value + }) + } + if (isStateChangingMethod(method)) { + const csrfToken = getCSRFToken() + if (csrfToken) retryHeaders['X-CSRF-Token'] = csrfToken + } const retryResponse = await fetch(url, { ...options, - headers, + headers: retryHeaders, credentials: 'include', }) - + if (retryResponse.ok) { return retryResponse.json() } + // * Retry failed — throw with the retry response status, not the original 401 + const retryBody = await retryResponse.json().catch(() => ({})) + throw new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status, retryBody) } else { const sessionExpiredMsg = authMessageFromStatus(401) onRefreshFailed(new ApiError(sessionExpiredMsg, 401)) localStorage.removeItem('user') + throw new ApiError(sessionExpiredMsg, 401) } } catch (e) { + if (e instanceof ApiError) throw e const err = e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError') ? new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0) : e diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index eeb8e90..ef5f7af 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' import { useRouter, usePathname } from 'next/navigation' +import { useSWRConfig } from 'swr' import apiRequest from '@/lib/api/client' import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-net/ui' import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth' @@ -51,6 +52,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [isLoggingOut, setIsLoggingOut] = useState(false) const router = useRouter() const pathname = usePathname() + const { mutate: swrMutate } = useSWRConfig() const refreshToken = useCallback(async (): Promise => { try { @@ -109,7 +111,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const refresh = useCallback(async () => { try { const userData = await apiRequest('/auth/user/me') - + setUser(prev => { const merged = { ...userData, @@ -122,8 +124,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } catch (e) { logger.error('Failed to refresh user data', e) } + // * Clear SWR cache so stale data isn't served after token refresh + swrMutate(() => true, undefined, { revalidate: true }) router.refresh() - }, [router]) + }, [router, swrMutate]) const refreshSession = useCallback(async () => { await refresh()