Merge pull request #246 from vernu/perf/dashboard-nav-speed

perf(web): make dashboard navigation fast
This commit is contained in:
vernu
2026-07-22 17:18:42 +03:00
committed by GitHub
29 changed files with 820 additions and 119 deletions

View File

@@ -2,11 +2,28 @@
import { Routes } from '@/config/routes'
import { toast } from '@/hooks/use-toast'
import { CredentialResponse, GoogleLogin } from '@react-oauth/google'
import {
CredentialResponse,
GoogleLogin,
GoogleOAuthProvider,
} from '@react-oauth/google'
import { signIn } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
// The provider lives here rather than in the app-wide tree so the Google
// Identity SDK is only loaded on the two pages that render this button
// (login and register) instead of on every dashboard page.
export default function LoginWithGoogle() {
return (
<GoogleOAuthProvider
clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? ''}
>
<GoogleLoginButton />
</GoogleOAuthProvider>
)
}
function GoogleLoginButton() {
const router = useRouter()
const searchParams = useSearchParams()
const redirect = searchParams.get('redirect')

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>

View File

@@ -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,

View File

@@ -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>
)

View File

@@ -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)
})
})

View File

@@ -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}/`)
}

View File

@@ -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>
)
}

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>

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>
)
}

View File

@@ -3,11 +3,33 @@
import { useState } from 'react'
import { useTheme } from 'next-themes'
import { Check, Copy } from 'lucide-react'
import SyntaxHighlighter from 'react-syntax-highlighter'
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'
import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'
import go from 'react-syntax-highlighter/dist/esm/languages/prism/go'
import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'
import json from 'react-syntax-highlighter/dist/esm/languages/prism/json'
import php from 'react-syntax-highlighter/dist/esm/languages/prism/php'
import python from 'react-syntax-highlighter/dist/esm/languages/prism/python'
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
// Prism, registering only the languages the guide uses. The bare
// react-syntax-highlighter entry is the highlight.js build with every language
// compiled in, and it emits hljs-* class names that the Prism themes below do
// not style, so this both shrinks the chunk and makes the theme apply.
// The languages come from LANGUAGES in snippets.ts plus json for responses.
for (const [name, definition] of [
['bash', bash],
['go', go],
['javascript', javascript],
['json', json],
['php', php],
['python', python],
] as const) {
SyntaxHighlighter.registerLanguage(name, definition)
}
// The previous guide hardcoded a dark slate background regardless of theme, so
// code blocks were the only dark thing on a light page.
export default function CodeBlock({

View File

@@ -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>
)
}

View File

@@ -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>
)
}

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()
})
})

View File

@@ -1,33 +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}>
<QueryClientProvider client={queryClient}>
<GoogleOAuthProvider
clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? ''}
>
{children}
</GoogleOAuthProvider>
</QueryClientProvider>
<SessionProvider session={session} refetchOnWindowFocus={false}>
<SessionTokenBridge />
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</SessionProvider>
)
}

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')
})
})

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(

View File

@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from '@testing-library/react'
import SupportHQWidget from './support-hq-widget'
const useSessionMock = vi.hoisted(() => vi.fn())
vi.mock('next-auth/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('next-auth/react')>()
return { ...actual, useSession: useSessionMock }
})
const init = vi.fn()
const destroy = vi.fn()
// A fresh object every call, the way SessionProvider hands one back after a
// refetch even when the underlying user has not changed.
const sessionWith = (overrides: Record<string, unknown> = {}) => ({
data: {
user: {
id: 'user_1',
name: 'Test User',
email: 'test@example.com',
phone: '',
...overrides,
},
},
})
const scripts = () =>
document.querySelectorAll('script[src*="supporthq-widget.js"]')
// jsdom does not fetch external scripts, so loading is simulated.
const fireLoad = () =>
scripts().forEach((s) => (s as HTMLScriptElement).onload?.(new Event('load')))
beforeEach(() => {
init.mockClear()
destroy.mockClear()
// @ts-ignore
window.SupportHQWidget = { init, destroy }
})
afterEach(() => {
document
.querySelectorAll('script[src*="supporthq-widget.js"]')
.forEach((s) => s.remove())
})
describe('SupportHQWidget', () => {
it('injects the widget script once and initialises with the user metadata', () => {
useSessionMock.mockReturnValue(sessionWith())
render(<SupportHQWidget />)
expect(scripts()).toHaveLength(1)
fireLoad()
expect(init).toHaveBeenCalledTimes(1)
expect(init.mock.calls[0][0].metadata).toEqual({
userId: 'user_1',
name: 'Test User',
email: 'test@example.com',
phone: '',
})
})
// The reported defect: the effect depended on the session object, so a
// refetch that returned identical data still destroyed and re-injected the
// widget, closing any chat the user had open.
it('does not reinitialise when a refetch returns identical data', () => {
useSessionMock.mockReturnValue(sessionWith())
const { rerender } = render(<SupportHQWidget />)
fireLoad()
useSessionMock.mockReturnValue(sessionWith())
rerender(<SupportHQWidget />)
expect(scripts()).toHaveLength(1)
expect(destroy).not.toHaveBeenCalled()
expect(init).toHaveBeenCalledTimes(1)
})
it('reinitialises when the user metadata actually changes', () => {
useSessionMock.mockReturnValue(sessionWith())
const { rerender } = render(<SupportHQWidget />)
fireLoad()
useSessionMock.mockReturnValue(sessionWith({ email: 'new@example.com' }))
rerender(<SupportHQWidget />)
fireLoad()
expect(destroy).toHaveBeenCalledTimes(1)
expect(init).toHaveBeenCalledTimes(2)
expect(init.mock.calls[1][0].metadata.email).toBe('new@example.com')
})
// destroy() tears down the widget but leaves the tag, so without an explicit
// removal every re-run left another one behind.
it('leaves no script tags behind when it unmounts', () => {
useSessionMock.mockReturnValue(sessionWith())
const { unmount } = render(<SupportHQWidget />)
fireLoad()
unmount()
expect(scripts()).toHaveLength(0)
expect(destroy).toHaveBeenCalledTimes(1)
})
it('does not initialise if the script loads after cleanup', () => {
useSessionMock.mockReturnValue(sessionWith())
const { unmount } = render(<SupportHQWidget />)
const pending = scripts()[0] as HTMLScriptElement
unmount()
// A cached src can resolve after the effect was torn down; initialising
// then would leave a widget that nothing destroys.
pending.onload?.(new Event('load'))
expect(init).not.toHaveBeenCalled()
})
it('omits metadata when nobody is signed in', () => {
useSessionMock.mockReturnValue({ data: null })
render(<SupportHQWidget />)
fireLoad()
expect(init).toHaveBeenCalledTimes(1)
expect(init.mock.calls[0][0]).not.toHaveProperty('metadata')
})
})

View File

@@ -3,32 +3,49 @@ import { useSession } from 'next-auth/react'
import React, { useEffect } from 'react'
export default function SupportHQWidget() {
const { data: session } = useSession()
const { data: session } = useSession()
useEffect(() => {
const script = document.createElement('script')
script.src = 'https://cdn.supporthq.app/widget/latest/supporthq-widget.js'
script.async = true
// @ts-ignore
script.onload = () => window.SupportHQWidget?.init({
projectId: process.env.NEXT_PUBLIC_SUPPORT_HQ_PROJECT_ID,
themeColor: process.env.NEXT_PUBLIC_SUPPORT_HQ_THEME_COLOR ?? '#2563eb',
...(session?.user && {
metadata: {
userId: session.user.id || '',
name: session.user.name || '',
email: session.user.email || '',
phone: session.user.phone || '',
}
})
})
document.body.appendChild(script)
// @ts-ignore
return () => { window.SupportHQWidget?.destroy() }
}, [session])
// Depended on as individual strings rather than as `session`: SessionProvider
// hands back a new object on every refetch, and comparing that by reference
// tore the widget down and re-injected its script even when nothing about the
// user had changed, closing any open chat.
const hasUser = Boolean(session?.user)
const userId = session?.user?.id ?? ''
const name = session?.user?.name ?? ''
const email = session?.user?.email ?? ''
const phone = session?.user?.phone ?? ''
return (
<></>
)
useEffect(() => {
let cancelled = false
const script = document.createElement('script')
script.src = 'https://cdn.supporthq.app/widget/latest/supporthq-widget.js'
script.async = true
script.onload = () => {
// The script can finish loading after this effect was cleaned up (the
// src is cached, so a re-run resolves immediately), and initialising
// then would leave a widget behind that nothing destroys.
if (cancelled) return
// @ts-ignore
window.SupportHQWidget?.init({
projectId: process.env.NEXT_PUBLIC_SUPPORT_HQ_PROJECT_ID,
themeColor: process.env.NEXT_PUBLIC_SUPPORT_HQ_THEME_COLOR ?? '#2563eb',
...(hasUser && {
metadata: { userId, name, email, phone },
}),
})
}
document.body.appendChild(script)
return () => {
cancelled = true
// @ts-ignore
window.SupportHQWidget?.destroy()
// destroy() tears down the widget but leaves this tag behind, so each
// re-run used to add another one for the life of the page.
script.remove()
}
}, [hasUser, userId, name, email, phone])
return <></>
}

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,

View File

@@ -55,14 +55,49 @@ test.describe('api guide (mocked API, no real backend)', () => {
await expect(page.getByText('package main').first()).toBeVisible()
})
// The guide applies Prism themes, so it must render with the Prism
// highlighter. It previously used the package's default export, which is the
// highlight.js build: that emits hljs-* classes the Prism theme never styles
// (so samples went uncoloured) and compiles in every language it supports.
test('samples are highlighted by Prism, in every language', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/api-guide')
for (const language of ['cURL', 'Node.js', 'Python', 'PHP', 'Go']) {
await page.getByRole('tab', { name: language, exact: true }).click()
await expect(
page.locator('pre code .token').first(),
`${language} samples must carry Prism token markup`
).toBeVisible()
}
// A registered language yields more than one token type; a missing one
// would fall back to a single undifferentiated text run.
expect(
await page.locator('pre code .token').count()
).toBeGreaterThan(5)
})
test('code can be copied', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.goto('/dashboard/messaging/api-guide')
// navigator.clipboard.writeText rejects when the document is not focused,
// which is the state a backgrounded page sits in while workers run in
// parallel. Granting permissions alone does not cover it.
await page.bringToFront()
await page.getByRole('button', { name: 'Copy code' }).first().click()
// The button relabels itself only after the async clipboard write resolves,
// so this is the signal that there is something to read back.
await expect(page.getByRole('button', { name: 'Copied' }).first()).toBeVisible()
const clipboard = await page.evaluate(() =>
navigator.clipboard.readText()
)
@@ -74,7 +109,10 @@ test.describe('api guide (mocked API, no real backend)', () => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/api-guide')
await page.waitForLoadState('networkidle')
// Not networkidle: link prefetching keeps the network busy, so that state
// can go unreached under parallel workers. A rendered sample proves the
// code blocks (the thing that would widen the page) are laid out.
await expect(page.getByText('curl -X POST').first()).toBeVisible()
// Code blocks are the classic way to widen a mobile page.
const { scrollWidth, innerWidth } = await page.evaluate(() => ({

View File

@@ -23,6 +23,20 @@ async function uploadCsv(page: import('@playwright/test').Page, csv: string) {
})
}
// The section streams behind a loading.tsx boundary, so the form's HTML can
// paint before React hydrates it, and a file offered during that gap is
// silently discarded (react-dropzone binds its change handler on mount).
// The row cap in the dropzone copy is derived from useSubscription inside this
// page's own hook, so seeing it proves this component hydrated and has data.
// Waiting on the devices response would not: the dashboard layout requests the
// same query key, so it can resolve before this page has hydrated.
async function gotoBulkPage(page: import('@playwright/test').Page) {
await page.goto('/dashboard/messaging/bulk')
await expect(
page.getByText(/Up to 1 MB and [\d,]+ rows on your plan/)
).toBeVisible()
}
// Keyboard selection rather than clicking the option: the Radix popper can
// render outside the viewport on a short page, which made the click flaky.
async function selectDevice(page: import('@playwright/test').Page) {
@@ -51,7 +65,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
})
})
await page.goto('/dashboard/messaging/bulk')
await gotoBulkPage(page)
await uploadCsv(page, CSV_WITH_PROBLEM_ROWS)
@@ -90,7 +104,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
test('offers a downloadable sample CSV', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/bulk')
await gotoBulkPage(page)
const link = page.getByRole('link', { name: /download a sample csv/i })
await expect(link).toBeVisible()
@@ -110,7 +124,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/bulk')
await gotoBulkPage(page)
// The old copy printed the raw byte count ("max 1048576 bytes").
await expect(page.getByText('1048576')).toHaveCount(0)
@@ -124,7 +138,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
await page.setViewportSize({ width: 375, height: 800 })
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/bulk')
await gotoBulkPage(page)
// A wide table is the obvious way to reintroduce the sideways-scroll bug,
// and it only exists once a file has been parsed.
@@ -144,7 +158,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/bulk')
await gotoBulkPage(page)
await uploadCsv(page, CSV_WITH_PROBLEM_ROWS)
await selectDevice(page)
@@ -165,7 +179,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/bulk')
await gotoBulkPage(page)
await uploadCsv(
page,
@@ -187,7 +201,7 @@ test.describe('bulk send (mocked API, no real backend)', () => {
test('explains why a non-CSV file was rejected', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging/bulk')
await gotoBulkPage(page)
// Rejected drops used to return silently, so nothing happened and nothing
// said why.

View File

@@ -42,23 +42,29 @@ test.describe('app chrome (mocked API, no real backend)', () => {
await mockApi(page)
await page.goto('/dashboard')
// Scroll to the very bottom so the footer is in view.
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
const footer = page.locator('footer')
await expect(footer).toBeVisible()
const tabBar = page.locator('nav.fixed').first()
const footerBox = await footer.boundingBox()
const tabBarBox = await page.locator('nav.fixed').first().boundingBox()
expect(footerBox, 'footer must have a layout box').not.toBeNull()
expect(tabBarBox, 'mobile tab bar must have a layout box').not.toBeNull()
// The footer's last pixel must sit above the tab bar's first pixel.
expect(
footerBox!.y + footerBox!.height,
'footer bottom must clear the fixed tab bar'
).toBeLessThanOrEqual(tabBarBox!.y + 1)
// Scrolled again on every attempt rather than once up front: the page
// starts as a short loading skeleton and grows as each card's data lands,
// so a single scroll to the bottom can be measured from a document that is
// still shorter than it ends up, leaving the footer below the viewport.
await expect
.poll(
async () => {
await page.evaluate(() =>
window.scrollTo(0, document.body.scrollHeight)
)
const footerBox = await footer.boundingBox()
const tabBarBox = await tabBar.boundingBox()
if (!footerBox || !tabBarBox) return Number.POSITIVE_INFINITY
// The footer's last pixel must sit above the tab bar's first pixel.
return footerBox.y + footerBox.height - (tabBarBox.y + 1)
},
{ message: 'footer bottom must clear the fixed tab bar' }
)
.toBeLessThanOrEqual(0)
})
test('mobile: footer links stack vertically', async ({ page, context }) => {

View File

@@ -15,7 +15,10 @@ const AUTHED_PAGES = [
'/dashboard/messaging/api-guide',
'/dashboard/webhooks',
'/dashboard/webhooks/deliveries',
'/dashboard/account',
// The billing tab, not /dashboard/account: that route is a redirect stub
// with no layout of its own, and measuring it raced the redirect. The
// redirect itself is covered in account.spec.ts.
'/dashboard/account/billing',
'/dashboard/account/profile',
'/dashboard/account/security',
'/dashboard/account/support',
@@ -24,13 +27,22 @@ const AUTHED_PAGES = [
'/dashboard/community',
]
// Deterministic replacement for waitForLoadState('networkidle'), which needs a
// 500ms window with zero connections. Link prefetching keeps issuing and
// aborting RSC requests, so under parallel workers that window can be missed
// until the test times out. Waiting for the page's own heading and for its
// loading placeholders to clear proves the same thing: content is laid out.
async function waitForContent(page: import('@playwright/test').Page) {
await expect(page.locator('main#main-content h2').first()).toBeVisible()
await expect(page.locator('main#main-content [role="status"]')).toHaveCount(0)
}
async function expectNoHorizontalScroll(page: import('@playwright/test').Page) {
const { scrollWidth, innerWidth } = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}))
expect(scrollWidth, 'page must not scroll sideways at 375px').toBeLessThanOrEqual(
innerWidth
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth
)
expect(overflow, 'page must not scroll sideways at 375px').toBeLessThanOrEqual(
0
)
}
@@ -42,7 +54,7 @@ test.describe('no horizontal overflow at 375px (mocked API)', () => {
await authenticate(context)
await mockApi(page)
await page.goto(path)
await page.waitForLoadState('networkidle')
await waitForContent(page)
await expectNoHorizontalScroll(page)
})
}
@@ -70,7 +82,7 @@ test.describe('no horizontal overflow at 375px (mocked API)', () => {
test('login page', async ({ page }) => {
await mockApi(page)
await page.goto('/login')
await page.waitForLoadState('networkidle')
await expect(page.getByText('Welcome back')).toBeVisible()
await expectNoHorizontalScroll(page)
})
})

View File

@@ -15,6 +15,16 @@ async function captureSend(page: import('@playwright/test').Page) {
return payloads
}
// The section streams behind a loading.tsx boundary, so the form's HTML can
// paint before React hydrates it; typing that early into the controlled To
// input is silently wiped when hydration commits. The device name renders
// only after the client-side devices fetch, so its presence proves the form
// is hydrated and its handlers are attached.
async function gotoSendPage(page: import('@playwright/test').Page) {
await page.goto('/dashboard/messaging')
await expect(page.getByLabel('Send from')).toContainText('Pixel 8')
}
test.describe('send sms (mocked API, no real backend)', () => {
test('commits recipients as chips and posts exactly those numbers', async ({
page,
@@ -24,7 +34,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
await mockApi(page)
const payloads = await captureSend(page)
await page.goto('/dashboard/messaging')
await gotoSendPage(page)
const to = page.getByLabel('To', { exact: true })
await to.fill('+1 (415) 555-0101')
@@ -57,7 +67,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging')
await gotoSendPage(page)
await page
.getByLabel('To', { exact: true })
@@ -77,7 +87,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging')
await gotoSendPage(page)
// Spaces are formatting inside one number here, not separators.
const to = page.getByLabel('To', { exact: true })
@@ -94,7 +104,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
test('rejects a number that cannot be dialled', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging')
await gotoSendPage(page)
const to = page.getByLabel('To', { exact: true })
await to.fill('not a number')
@@ -113,7 +123,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
await mockApi(page)
const payloads = await captureSend(page)
await page.goto('/dashboard/messaging')
await gotoSendPage(page)
// Type a number then go straight to the message without pressing Enter.
await page.getByLabel('To', { exact: true }).fill('+14155550101')
@@ -127,7 +137,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
test('preselects the only enabled device', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging')
await gotoSendPage(page)
// Fixtures have one enabled device (Pixel 8) and one disabled. The old
// implementation computed this in defaultValues before devices loaded, so
@@ -138,7 +148,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
test('counts SMS segments', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard/messaging')
await gotoSendPage(page)
await page.getByLabel('Message').fill('a'.repeat(161))
await expect(page.getByText(/161 characters, 2 segments/)).toBeVisible()
@@ -149,7 +159,7 @@ test.describe('send sms (mocked API, no real backend)', () => {
await mockApi(page)
await captureSend(page)
await page.goto('/dashboard/messaging')
await gotoSendPage(page)
const to = page.getByLabel('To', { exact: true })
await to.fill('+14155550101')
await to.press('Enter')

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)
})

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,
})
}

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'])
})
})

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'
}
}

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')>()