mirror of
https://github.com/vernu/textbee.git
synced 2026-09-03 03:29:58 +03:00
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:
co-authored by
Claude Fable 5
parent
c36cfc12a6
commit
18e7d1b3d8
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 ?? ''}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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')>()
|
||||
|
||||
Reference in New Issue
Block a user