Files
textbee/web/lib/httpBrowserClient.ts
isra el 18e7d1b3d8 perf(web): make dashboard navigation fast and cut session fetches
- prefetch route-tab links so tab clicks reuse a cached payload instead
  of paying an uncached server round trip each time
- add loading.tsx boundaries (dashboard root and each section) so clicks
  paint within a frame; section-level files keep the header and tabs
  mounted while only the content area swaps
- link the sidebar Account item and the checkout page straight to
  /dashboard/account/billing. The /dashboard/account redirect stub stays
  for old links, but no internal link pays the extra hop anymore. The
  stub also dropped query params, which silently ate the plan-change
  success toast.
- set QueryClient defaults (staleTime 60s, no focus refetch, retry 1);
  mutations already invalidate their keys, so the user's own changes
  stay instant. Device messages and webhook deliveries get a 15s
  staleTime since they change from outside the tab.
- replace the axios getCachedSession TTL cache with a token seeded from
  the server session in Providers and kept in sync by a session bridge.
  Requests attach the token synchronously; /api/auth/session is only a
  deduped fallback, instead of a refetch every 2 minutes with a
  thundering herd on expiry.
- swap the billing card's 16px loading spinner for a card-shaped
  skeleton so the tab no longer looks blank while loading

Tests: interceptor seeding/dedupe/signed-out behavior, provider
defaults and token seeding, nav active-state matching, tab prefetch,
billing loading state, plus e2e coverage for direct-to-billing
navigation and an at-most-one-session-call budget guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 13:49:14 +03:00

68 lines
2.1 KiB
TypeScript

import axios from 'axios'
import { getSession } from 'next-auth/react'
const httpBrowserClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '',
})
// API access token, seeded from the server-fetched session by Providers and
// kept current by its SessionTokenBridge. Held in module state so the request
// interceptor attaches it synchronously instead of paying a /api/auth/session
// round trip per request (Vercel cost and added latency). undefined means not
// seeded yet; null means known signed out, so auth pages never fetch either.
let sessionToken: string | null | undefined
export function setSessionToken(token: string | null) {
sessionToken = token
}
// Fallback for the rare request that fires before the token is seeded (deep
// hard load). One shared promise so a burst of queries costs one session call.
let sessionFetch: Promise<string | null> | null = null
function fetchSessionToken() {
if (!sessionFetch) {
sessionFetch = getSession()
.then((session) => {
sessionToken = session?.user?.accessToken ?? null
return sessionToken
})
.finally(() => {
sessionFetch = null
})
}
return sessionFetch
}
httpBrowserClient.interceptors.request.use(async (config) => {
const token =
sessionToken === undefined ? await fetchSessionToken() : sessionToken
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Global session-expiry handling: any 401 from the API means the stored token
// is no longer valid, so send the user to logout. This replaces the previous
// per-navigation whoAmI check in the layout wrapper.
httpBrowserClient.interceptors.response.use(
(response) => response,
(error) => {
if (
typeof window !== 'undefined' &&
error?.response?.status === 401
) {
const { pathname } = window.location
if (!pathname.includes('/logout') && !pathname.includes('/login')) {
setSessionToken(null)
window.location.href = '/logout'
}
}
return Promise.reject(error)
}
)
export default httpBrowserClient