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:
@@ -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.
|
||||
|
||||
@@ -244,21 +244,30 @@ async function apiRequest<T>(
|
||||
// * If refresh is already in progress, wait for it to complete (or fail)
|
||||
return new Promise<T>((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<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
|
||||
}
|
||||
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<T>(
|
||||
// * Refresh successful, cookies updated
|
||||
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, {
|
||||
...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
|
||||
|
||||
@@ -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<boolean> => {
|
||||
try {
|
||||
@@ -109,7 +111,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const userData = await apiRequest<User>('/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()
|
||||
|
||||
Reference in New Issue
Block a user