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.
This commit is contained in:
Usman Baig
2026-03-13 10:52:02 +01:00
parent f7340fa763
commit 1c26e4cc6c
3 changed files with 51 additions and 19 deletions

View File

@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Fixed ### 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. - **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. - **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. - **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.

View File

@@ -244,21 +244,30 @@ async function apiRequest<T>(
// * If refresh is already in progress, wait for it to complete (or fail) // * If refresh is already in progress, wait for it to complete (or fail)
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
subscribeToTokenRefresh( subscribeToTokenRefresh(
async () => { () => {
try { // * Retry with fresh headers after refresh completes
const retryResponse = await fetch(url, { const retryHeaders: Record<string, string> = {
...options, 'Content-Type': 'application/json',
headers, [getRequestIdHeader()]: generateRequestId(),
credentials: 'include',
})
if (retryResponse.ok) {
resolve(await retryResponse.json())
} else {
reject(new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status))
}
} catch (e) {
reject(e)
} }
if (options.headers) {
Object.entries(options.headers as Record<string, string>).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) (err) => reject(err)
) )
@@ -279,22 +288,40 @@ async function apiRequest<T>(
// * Refresh successful, cookies updated // * Refresh successful, cookies updated
onRefreshed() onRefreshed()
// * Retry original request // * Retry original request with fresh headers
const retryHeaders: Record<string, string> = {
'Content-Type': 'application/json',
[getRequestIdHeader()]: generateRequestId(),
}
if (options.headers) {
Object.entries(options.headers as Record<string, string>).forEach(([key, value]) => {
retryHeaders[key] = value
})
}
if (isStateChangingMethod(method)) {
const csrfToken = getCSRFToken()
if (csrfToken) retryHeaders['X-CSRF-Token'] = csrfToken
}
const retryResponse = await fetch(url, { const retryResponse = await fetch(url, {
...options, ...options,
headers, headers: retryHeaders,
credentials: 'include', credentials: 'include',
}) })
if (retryResponse.ok) { if (retryResponse.ok) {
return retryResponse.json() 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 { } else {
const sessionExpiredMsg = authMessageFromStatus(401) const sessionExpiredMsg = authMessageFromStatus(401)
onRefreshFailed(new ApiError(sessionExpiredMsg, 401)) onRefreshFailed(new ApiError(sessionExpiredMsg, 401))
localStorage.removeItem('user') localStorage.removeItem('user')
throw new ApiError(sessionExpiredMsg, 401)
} }
} catch (e) { } catch (e) {
if (e instanceof ApiError) throw e
const err = e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError') const err = e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')
? new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0) ? new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
: e : e

View File

@@ -2,6 +2,7 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { useRouter, usePathname } from 'next/navigation' import { useRouter, usePathname } from 'next/navigation'
import { useSWRConfig } from 'swr'
import apiRequest from '@/lib/api/client' import apiRequest from '@/lib/api/client'
import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-net/ui' import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-net/ui'
import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth' 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 [isLoggingOut, setIsLoggingOut] = useState(false)
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
const { mutate: swrMutate } = useSWRConfig()
const refreshToken = useCallback(async (): Promise<boolean> => { const refreshToken = useCallback(async (): Promise<boolean> => {
try { try {
@@ -109,7 +111,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const userData = await apiRequest<User>('/auth/user/me') const userData = await apiRequest<User>('/auth/user/me')
setUser(prev => { setUser(prev => {
const merged = { const merged = {
...userData, ...userData,
@@ -122,8 +124,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} catch (e) { } catch (e) {
logger.error('Failed to refresh user data', 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.refresh()
}, [router]) }, [router, swrMutate])
const refreshSession = useCallback(async () => { const refreshSession = useCallback(async () => {
await refresh() await refresh()