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>
This commit is contained in:
isra el
2026-07-22 13:49:14 +03:00
co-authored by Claude Fable 5
parent c36cfc12a6
commit 18e7d1b3d8
20 changed files with 487 additions and 44 deletions
+5 -3
View File
@@ -117,7 +117,9 @@ export default function CheckoutPage({
planName,
billingInterval: urlInterval ?? selected,
})
window.location.href = '/dashboard/account?plan-change-success=1'
// Straight to the billing tab: the /dashboard/account redirect stub
// drops query params, which silently ate the success toast.
window.location.href = '/dashboard/account/billing?plan-change-success=1'
} catch (error) {
// no auto-retry here: the request may have charged the card
setPlanChange(null)
@@ -175,7 +177,7 @@ export default function CheckoutPage({
Try again
</Button>
<Button variant='ghost' asChild>
<Link href='/dashboard/account'>Back to your account</Link>
<Link href='/dashboard/account/billing'>Back to your account</Link>
</Button>
</div>
</div>
@@ -233,7 +235,7 @@ export default function CheckoutPage({
disabled={isConfirming}
asChild
>
<Link href='/dashboard/account'>Cancel</Link>
<Link href='/dashboard/account/billing'>Cancel</Link>
</Button>
</div>
</CheckoutShell>
@@ -216,6 +216,21 @@ describe('SubscriptionInfo', () => {
).toHaveAttribute('href', 'https://textbee.dev/pricing')
})
// The loading state used to be a 16px spinner alone in the content column,
// which read as a blank tab while the subscription loaded.
it('shows a visible loading skeleton while the subscription loads', () => {
useSubscription.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
})
render(<SubscriptionInfo />)
expect(screen.getByRole('status')).toHaveTextContent(
'Loading subscription'
)
})
it('renders an error state rather than an empty card', () => {
useSubscription.mockReturnValue({
data: undefined,
@@ -11,7 +11,7 @@ import {
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { Skeleton } from '@/components/ui/skeleton'
import { useCurrentUser, useSubscription } from '@/lib/api'
import type { SubscriptionStatus } from '@/lib/api/types'
import {
@@ -235,8 +235,12 @@ export default function SubscriptionInfo() {
if (isLoadingSubscription)
return (
<div className='flex h-full min-h-[200px] items-center justify-center'>
<Spinner size='sm' />
// Mirrors the two cards below. The old 16px spinner in an empty column
// read as a blank page while the subscription loaded.
<div role='status' className='space-y-4'>
<span className='sr-only'>Loading subscription</span>
<Skeleton className='h-44 w-full rounded-lg' />
<Skeleton className='h-56 w-full rounded-lg' />
</div>
)
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { isNavItemActive, navItems } from './nav-items'
describe('isNavItemActive', () => {
it('matches the dashboard home exactly', () => {
expect(isNavItemActive({ href: '/dashboard' }, '/dashboard')).toBe(true)
expect(isNavItemActive({ href: '/dashboard' }, '/dashboard/messaging')).toBe(
false
)
})
it('matches section subroutes by prefix', () => {
const messaging = { href: '/dashboard/messaging' }
expect(isNavItemActive(messaging, '/dashboard/messaging')).toBe(true)
expect(isNavItemActive(messaging, '/dashboard/messaging/bulk')).toBe(true)
expect(isNavItemActive(messaging, '/dashboard/messaging/api-guide')).toBe(
true
)
})
it('does not match sibling routes that share a name prefix', () => {
expect(
isNavItemActive({ href: '/dashboard/message' }, '/dashboard/messaging')
).toBe(false)
})
it('keeps Account active across the section while linking to billing', () => {
const account = navItems.find((item) => item.label === 'Account')
// Direct link skips the /dashboard/account redirect stub.
expect(account?.href).toBe('/dashboard/account/billing')
expect(isNavItemActive(account!, '/dashboard/account/billing')).toBe(true)
expect(isNavItemActive(account!, '/dashboard/account/profile')).toBe(true)
expect(isNavItemActive(account!, '/dashboard/account/security')).toBe(true)
expect(isNavItemActive(account!, '/dashboard/account')).toBe(true)
expect(isNavItemActive(account!, '/dashboard/community')).toBe(false)
})
})
@@ -11,6 +11,10 @@ export type NavItem = {
href: string
label: string
icon: LucideIcon
// Active-state prefix when it differs from href (Account links straight to
// billing so the click skips the /dashboard/account redirect stub, but must
// stay highlighted on every account tab).
match?: string
// The mobile tab bar caps at 4 items (375px width); items marked
// mobileHidden appear only in the desktop sidebar and the command palette.
mobileHidden?: boolean
@@ -23,15 +27,24 @@ export const navItems: NavItem[] = [
{ href: '/dashboard/messaging', label: 'Messaging', icon: MessageSquareText },
{ href: '/dashboard/webhooks', label: 'Webhooks', icon: Webhook, mobileHidden: true },
{ href: '/dashboard/community', label: 'Community', icon: Users },
{ href: '/dashboard/account', label: 'Account', icon: UserCircle },
{
href: '/dashboard/account/billing',
label: 'Account',
icon: UserCircle,
match: '/dashboard/account',
},
]
export const mobileNavItems = navItems.filter((item) => !item.mobileHidden)
// /dashboard must match exactly; deeper routes match by prefix so nested pages
// keep their parent highlighted.
export function isNavItemActive(href: string, pathname: string): boolean {
return href === '/dashboard'
? pathname === href
: pathname === href || pathname.startsWith(`${href}/`) || pathname.startsWith(href)
export function isNavItemActive(
item: Pick<NavItem, 'href' | 'match'>,
pathname: string
): boolean {
const prefix = item.match ?? item.href
return prefix === '/dashboard'
? pathname === prefix
: pathname === prefix || pathname.startsWith(`${prefix}/`)
}
@@ -0,0 +1,15 @@
import { Skeleton } from '@/components/ui/skeleton'
// Shown in the content slot while a tab's page segment streams in; the
// section header and tabs from the layout stay mounted above it. max-w-2xl
// matches the account pages (billing, profile, security, support).
export default function Loading() {
return (
<div role='status' className='max-w-2xl space-y-4'>
<span className='sr-only'>Loading</span>
<Skeleton className='h-8 w-48' />
<Skeleton className='h-28 w-full rounded-lg' />
<Skeleton className='h-28 w-full rounded-lg' />
</div>
)
}
+2 -2
View File
@@ -57,7 +57,7 @@ export default function DashboardLayout({
<SidebarLink
key={item.href}
item={item}
isActive={isNavItemActive(item.href, pathname)}
isActive={isNavItemActive(item, pathname)}
/>
))}
</nav>
@@ -114,7 +114,7 @@ export default function DashboardLayout({
<MobileNavLink
key={item.href}
item={item}
isActive={isNavItemActive(item.href, pathname)}
isActive={isNavItemActive(item, pathname)}
/>
))}
</div>
+20
View File
@@ -0,0 +1,20 @@
import { Skeleton } from '@/components/ui/skeleton'
// Section-level fallback for dashboard routes without a nested loading file
// (home, community). Padding mirrors those pages so nothing shifts when the
// real content lands. Sections with their own layout (messaging, webhooks,
// account) use their nested loading files instead, which keep the tabs
// mounted during tab switches.
export default function Loading() {
return (
<div role='status' className='flex-1 space-y-6 p-4 sm:p-6 md:p-8'>
<span className='sr-only'>Loading</span>
<div className='space-y-2'>
<Skeleton className='h-8 w-56' />
<Skeleton className='h-4 w-72' />
</div>
<Skeleton className='h-32 w-full rounded-xl' />
<Skeleton className='h-32 w-full rounded-xl' />
</div>
)
}
@@ -0,0 +1,14 @@
import { Skeleton } from '@/components/ui/skeleton'
// Shown in the content slot while a tab's page segment streams in; the
// section header and tabs from the layout stay mounted above it.
export default function Loading() {
return (
<div role='status' className='max-w-3xl space-y-4'>
<span className='sr-only'>Loading</span>
<Skeleton className='h-8 w-48' />
<Skeleton className='h-28 w-full rounded-lg' />
<Skeleton className='h-28 w-full rounded-lg' />
</div>
)
}
@@ -0,0 +1,14 @@
import { Skeleton } from '@/components/ui/skeleton'
// Shown in the content slot while a tab's page segment streams in; the
// section header and tabs from the layout stay mounted above it.
export default function Loading() {
return (
<div role='status' className='max-w-3xl space-y-4'>
<span className='sr-only'>Loading</span>
<Skeleton className='h-8 w-48' />
<Skeleton className='h-28 w-full rounded-lg' />
<Skeleton className='h-28 w-full rounded-lg' />
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { useQueryClient } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import Providers from './providers'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { server } from '@/test/msw/server'
import { API_BASE_URL, TEST_ACCESS_TOKEN } from '@/test/fixtures'
import { mockSession } from '@/test/render'
// Own getSession mock (overriding the global one in test/setup.ts) so the
// no-session-fetch guarantee below is a real call-count assertion.
const getSessionSpy = vi.hoisted(() => vi.fn())
vi.mock('next-auth/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('next-auth/react')>()
return { ...actual, getSession: getSessionSpy }
})
function DefaultsProbe() {
const queries = useQueryClient().getDefaultOptions().queries
return (
<div data-testid='defaults'>
{`${queries?.staleTime}|${String(queries?.refetchOnWindowFocus)}|${String(
queries?.retry
)}`}
</div>
)
}
describe('Providers', () => {
it('sets query defaults: 60s staleTime, no focus refetch, one retry', () => {
render(
<Providers session={mockSession}>
<DefaultsProbe />
</Providers>
)
expect(screen.getByTestId('defaults')).toHaveTextContent('60000|false|1')
})
it('seeds the API token from the server session with zero session fetches', async () => {
let authHeader: string | null = null
server.use(
http.get(`${API_BASE_URL}/ping`, ({ request }) => {
authHeader = request.headers.get('authorization')
return HttpResponse.json({ ok: true })
})
)
render(<Providers session={mockSession}>{null}</Providers>)
await httpBrowserClient.get('/ping')
expect(authHeader).toBe(`Bearer ${TEST_ACCESS_TOKEN}`)
expect(getSessionSpy).not.toHaveBeenCalled()
})
})
+39 -6
View File
@@ -1,26 +1,59 @@
'use client'
import { GoogleOAuthProvider } from '@react-oauth/google'
import { SessionProvider } from 'next-auth/react'
import { SessionProvider, useSession } from 'next-auth/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState, type PropsWithChildren } from 'react'
import { useEffect, useState, type PropsWithChildren } from 'react'
import type { Session } from 'next-auth'
import { setSessionToken } from '@/lib/httpBrowserClient'
// Keeps the API client's token in sync with the session (login, logout, tab
// broadcast). Reads from context only: the provider is seeded with the server
// session, so this never hits /api/auth/session.
function SessionTokenBridge() {
const { data: session } = useSession()
useEffect(() => {
setSessionToken(session?.user?.accessToken ?? null)
}, [session])
return null
}
// Client-side provider tree for the app. The QueryClient is created once via
// useState so it is stable across re-renders (previously a new client was
// constructed on every render, throwing away the cache). Session expiry is
// handled globally by a 401 response interceptor in httpBrowserClient, so there
// is no longer a per-navigation whoAmI check here.
// handled globally by a 401 response interceptor in httpBrowserClient, so
// SessionProvider does not refetch on window focus and there is no
// per-navigation whoAmI check here.
export default function Providers({
session,
children,
}: PropsWithChildren<{ session: Session | null }>) {
const [queryClient] = useState(() => new QueryClient())
const [queryClient] = useState(() => {
// Seeded during the first render, before any child mounts and queries.
setSessionToken(session?.user?.accessToken ?? null)
// staleTime 60s: mutations invalidate their keys, so the user's own
// changes are always instant; only out-of-tab changes can lag a minute.
// The old defaults (staleTime 0, refetch on focus) refetched every query
// on every mount and window focus.
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
})
})
// ThemeProvider lives in the root layout (app/theme-provider.tsx) so its
// pre-paint script is not re-rendered on client navigation.
return (
<SessionProvider session={session}>
<SessionProvider session={session} refetchOnWindowFocus={false}>
<SessionTokenBridge />
<QueryClientProvider client={queryClient}>
<GoogleOAuthProvider
clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? ''}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import RouteTabs from './route-tabs'
// Capture Link props: prefetch is router behavior, invisible in the DOM.
vi.mock('next/link', () => ({
default: ({ children, href, prefetch, ...rest }: any) => (
<a href={href} data-prefetch={String(prefetch)} {...rest}>
{children}
</a>
),
}))
vi.mock('next/navigation', () => ({
usePathname: () => '/dashboard/messaging',
}))
const tabs = [
{ href: '/dashboard/messaging', label: 'Send', exact: true },
{ href: '/dashboard/messaging/bulk', label: 'Bulk Send' },
{ href: '/dashboard/messaging/history', label: 'History' },
]
describe('RouteTabs', () => {
it('opts every tab link into full prefetch', () => {
render(<RouteTabs tabs={tabs} />)
// These routes are dynamic (session cookie in the app layout); without an
// explicit prefetch every tab click pays a full server round trip.
for (const link of screen.getAllByRole('link')) {
expect(link).toHaveAttribute('data-prefetch', 'true')
}
expect(screen.getAllByRole('link')).toHaveLength(tabs.length)
})
it('marks only the active tab with aria-current', () => {
render(<RouteTabs tabs={tabs} />)
expect(screen.getByRole('link', { name: 'Send' })).toHaveAttribute(
'aria-current',
'page'
)
expect(
screen.getByRole('link', { name: 'Bulk Send' })
).not.toHaveAttribute('aria-current')
})
})
+4 -1
View File
@@ -20,7 +20,9 @@ function isTabActive(tab: RouteTab, pathname: string): boolean {
// Link-based segmented control: tabs are real routes, so the active tab
// survives refresh and deep links are shareable. Mobile: horizontally
// scrollable pills; the active pill scrolls into view on load.
// scrollable pills; the active pill scrolls into view on load. Links opt into
// full prefetch because these routes are dynamic (session cookie in the app
// layout), which the router's default prefetch skips.
export default function RouteTabs({
tabs,
className,
@@ -54,6 +56,7 @@ export default function RouteTabs({
<Link
key={tab.href}
href={tab.href}
prefetch
ref={active ? activeRef : undefined}
aria-current={active ? 'page' : undefined}
className={cn(
+27
View File
@@ -65,6 +65,33 @@ test.describe('account settings (mocked API, no real backend)', () => {
).toHaveCount(0)
})
// The sidebar used to link to /dashboard/account, whose page is a server
// redirect stub, so every click paid navigation + redirect + navigation.
test('the sidebar Account link goes straight to billing with no redirect hop', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
const stubHits: string[] = []
page.on('request', (request) => {
if (new URL(request.url()).pathname === '/dashboard/account') {
stubHits.push(request.url())
}
})
await page.goto('/dashboard')
await page
.getByRole('navigation', { name: 'Main' })
.getByRole('link', { name: 'Account' })
.click()
await expect(page).toHaveURL(/\/dashboard\/account\/billing$/)
await expect(page.getByRole('heading', { name: 'Pro' })).toBeVisible()
expect(stubHits).toEqual([])
})
test('the pricing page is reachable from billing on any plan', async ({
page,
context,
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from '@playwright/test'
import { authenticate } from './session'
import { mockApi } from './mock-api'
// Serverless-cost regression guard. The API client's token is seeded from the
// server-fetched session, so browsing the dashboard must not keep calling
// /api/auth/session. Before this guard, the axios interceptor refetched the
// session every 2 minutes with no dedupe, so a burst of queries fanned out
// one session call each.
test('dashboard navigation makes at most one /api/auth/session call', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
let sessionCalls = 0
page.on('request', (request) => {
if (new URL(request.url()).pathname === '/api/auth/session') {
sessionCalls += 1
}
})
await page.goto('/dashboard')
await expect(
page.getByRole('heading', { name: 'Welcome back, Test', level: 2 })
).toBeVisible()
const mainNav = page.getByRole('navigation', { name: 'Main' })
const tabs = page.getByRole('navigation', { name: 'Section navigation' })
await mainNav.getByRole('link', { name: 'Messaging' }).click()
await expect(page).toHaveURL(/\/dashboard\/messaging$/)
await tabs.getByRole('link', { name: 'History' }).click()
await expect(page).toHaveURL(/\/dashboard\/messaging\/history$/)
await mainNav.getByRole('link', { name: 'Webhooks' }).click()
await expect(page).toHaveURL(/\/dashboard\/webhooks$/)
await tabs.getByRole('link', { name: 'Deliveries' }).click()
await expect(page).toHaveURL(/\/dashboard\/webhooks\/deliveries$/)
await mainNav.getByRole('link', { name: 'Account' }).click()
await expect(page).toHaveURL(/\/dashboard\/account\/billing$/)
expect(sessionCalls).toBeLessThanOrEqual(1)
})
+6
View File
@@ -358,6 +358,9 @@ export function useWebhookNotifications(filters: WebhookNotificationFilters) {
`${ApiEndpoints.gateway.getWebhookNotifications()}?eventType=${eventType}&page=${page}&limit=${limit}&status=${status}&start=${start}&end=${end}&deviceId=${deviceId}&webhookSubscriptionId=${webhookSubscriptionId}`
)
.then(unwrapBody<WebhookNotificationsEnvelope>),
// Deliveries arrive from outside the tab, so stay fresher than the 60s
// client-wide default.
staleTime: 15_000,
})
}
@@ -420,6 +423,9 @@ export function useDeviceMessages(
.get(`${ApiEndpoints.gateway.getMessages(deviceId)}?${query}`)
.then(unwrapBody<DeviceMessagesEnvelope>)
},
// Inbound messages arrive from outside the tab, so stay fresher than the
// 60s client-wide default. Before ...options so callers can override.
staleTime: 15_000,
...options,
})
}
+83
View File
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '@/test/msw/server'
import { API_BASE_URL } from '@/test/fixtures'
// Own getSession mock (overriding the global one in test/setup.ts) so calls
// can be counted and resolved per test.
const getSessionMock = vi.hoisted(() => vi.fn())
vi.mock('next-auth/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('next-auth/react')>()
return { ...actual, getSession: getSessionMock }
})
// The token lives in module state, so each test loads a fresh module copy.
async function loadClient() {
vi.resetModules()
return import('@/lib/httpBrowserClient')
}
function captureAuthHeaders() {
const headers: (string | null)[] = []
server.use(
http.get(`${API_BASE_URL}/ping`, ({ request }) => {
headers.push(request.headers.get('authorization'))
return HttpResponse.json({ ok: true })
})
)
return headers
}
afterEach(() => {
getSessionMock.mockReset()
})
describe('httpBrowserClient auth interceptor', () => {
it('attaches a seeded token without calling getSession', async () => {
const { default: client, setSessionToken } = await loadClient()
setSessionToken('abc')
const headers = captureAuthHeaders()
await client.get('/ping')
expect(headers).toEqual(['Bearer abc'])
expect(getSessionMock).not.toHaveBeenCalled()
})
it('dedupes concurrent session fetches when the token is not seeded', async () => {
getSessionMock.mockResolvedValue({ user: { accessToken: 'tok1' } })
const { default: client } = await loadClient()
const headers = captureAuthHeaders()
await Promise.all([client.get('/ping'), client.get('/ping')])
expect(getSessionMock).toHaveBeenCalledTimes(1)
expect(headers).toEqual(['Bearer tok1', 'Bearer tok1'])
})
it('sends no header and no session fetch when seeded signed out', async () => {
const { default: client, setSessionToken } = await loadClient()
// null means known signed out (the 401 handler and the auth pages), so
// requests must not fall back to a /api/auth/session round trip.
setSessionToken(null)
const headers = captureAuthHeaders()
await client.get('/ping')
expect(headers).toEqual([null])
expect(getSessionMock).not.toHaveBeenCalled()
})
it('recovers with a fresh fetch after a failed session lookup', async () => {
getSessionMock.mockRejectedValueOnce(new Error('network down'))
getSessionMock.mockResolvedValueOnce({ user: { accessToken: 'tok2' } })
const { default: client } = await loadClient()
const headers = captureAuthHeaders()
await expect(client.get('/ping')).rejects.toThrow('network down')
await client.get('/ping')
expect(getSessionMock).toHaveBeenCalledTimes(2)
expect(headers).toEqual(['Bearer tok2'])
})
})
+30 -21
View File
@@ -5,32 +5,41 @@ const httpBrowserClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '',
})
// Cache for session data to reduce API calls
let sessionCache: any = null
let cacheTimestamp = 0
const CACHE_DURATION = 2 * 60 * 1000 // 2 minutes
// 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
const getCachedSession = async () => {
const now = Date.now()
// Return cached session if it's still valid
if (sessionCache && (now - cacheTimestamp) < CACHE_DURATION) {
return sessionCache
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
})
}
// Fetch fresh session and update cache
const session = await getSession()
sessionCache = session
cacheTimestamp = now
return session
return sessionFetch
}
httpBrowserClient.interceptors.request.use(async (config) => {
const session = await getCachedSession()
const token =
sessionToken === undefined ? await fetchSessionToken() : sessionToken
if (session?.user?.accessToken) {
config.headers.Authorization = `Bearer ${session.user.accessToken}`
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
@@ -47,7 +56,7 @@ httpBrowserClient.interceptors.response.use(
) {
const { pathname } = window.location
if (!pathname.includes('/logout') && !pathname.includes('/login')) {
sessionCache = null
setSessionToken(null)
window.location.href = '/logout'
}
}
+5 -3
View File
@@ -3,9 +3,11 @@ import { cleanup } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, vi } from 'vitest'
import { server } from './msw/server'
// The axios browser client's interceptor calls next-auth's getSession(), which
// would otherwise trigger a real /api/auth/session fetch. Mock it so the
// interceptor can attach the Bearer token without any network access.
// The axios browser client's interceptor falls back to next-auth's
// getSession() when no token has been seeded (tests render components without
// the app's Providers), which would otherwise trigger a real /api/auth/session
// fetch. Mock it so the interceptor can attach the Bearer token without any
// network access.
const hoisted = vi.hoisted(() => ({ accessToken: 'test-access-token' }))
vi.mock('next-auth/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('next-auth/react')>()