fix: repair two cache invalidations that silently matched nothing

Both bugs were hand-written query keys. lib/api/query-keys.ts exists to
prevent exactly this and neither site used it.

1. /auth/who-am-i was cached under two keys. Four call sites used
   ['whoAmI'] and seven used ['currentUser'], including the typed
   useCurrentUser hook. So the endpoint was fetched twice on any page
   using both, and invalidating one never invalidated the other:
   edit-profile-form refreshed ['currentUser'] and left the email
   verification banner reading stale data under ['whoAmI'], while
   use-onboarding had the mirror-image bug.

   verify-email/page.tsx additionally cached the whole axios response
   where the others cached the unwrapped user, so unifying the key meant
   normalising that shape too. One key demands one shape.

2. generate-api-key invalidated ['apiKeys', 'stats'], which no query
   uses. react-query matches by prefix, so it matched neither the key
   list (['apiKeys', status]) nor the dashboard stats (['stats']).
   Generating an API key refreshed nothing at all. It is now three
   invalidations against queryKeys, using a new apiKeysAll prefix so
   every status filter refreshes rather than just 'active'.

Also added accountDeletionRequestedAt to the User type. It is a real
field on the user schema and the deletion banner already reads it; only
the type did not know about it.

Both bugs are invisible on inspection, which is how they shipped, so the
regression tests were each confirmed to fail against the pre-change code
before being trusted.

Verified: build clean, 0 lint errors (21 warnings, unchanged), 155 unit
tests (from 153), 78 e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isra el
2026-07-19 04:02:30 +03:00
parent 366a47913b
commit c1942a4ba3
8 changed files with 160 additions and 20 deletions

View File

@@ -17,6 +17,7 @@ import { Loader2, CheckCircle, XCircle, Mail, ArrowRight } from 'lucide-react'
import { useMutation, useQuery } from '@tanstack/react-query'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { queryKeys } from '@/lib/api/query-keys'
import { Routes } from '@/config/routes'
// Reusable components
@@ -82,17 +83,22 @@ export default function VerifyEmailPage() {
const [errorMessage, setErrorMessage] = useState<string>('')
// Check user authentication and email verification status
const {
data: whoAmIData,
// Unwrapped to the user here rather than cached as the raw axios response.
// Every consumer of this endpoint now shares one key, so they must also
// share one cached shape.
const {
data: user,
isPending: isCheckingAuth,
isError: isAuthError
isError: isAuthError
} = useQuery({
queryKey: ['whoAmI'],
queryFn: () => httpBrowserClient.get(ApiEndpoints.auth.whoAmI()),
queryKey: queryKeys.currentUser,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.whoAmI())
.then((res) => res.data?.data),
retry: 1,
})
const user = whoAmIData?.data?.data
const isEmailVerified = !!user?.emailVerifiedAt
const isLoggedIn = !isAuthError && !!user

View File

@@ -2,6 +2,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { ApiEndpoints } from '@/config/api'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { useQuery } from '@tanstack/react-query'
import { queryKeys } from '@/lib/api/query-keys'
import { AlertTriangle } from 'lucide-react'
export default function AccountDeletionAlert() {
@@ -10,7 +11,7 @@ export default function AccountDeletionAlert() {
isLoading: isLoadingUserData,
error: userDataError,
} = useQuery({
queryKey: ['whoAmI'],
queryKey: queryKeys.currentUser,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.whoAmI())

View File

@@ -12,6 +12,7 @@ import { Routes } from '@/config/routes'
import { useToast } from '@/hooks/use-toast'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { queryKeys } from '@/lib/api/query-keys'
import { QrCode, Copy, Smartphone, Download, AlertTriangle } from 'lucide-react'
import React, { forwardRef, useImperativeHandle, useState } from 'react'
import QRCode from 'react-qr-code'
@@ -59,9 +60,14 @@ const GenerateApiKey = forwardRef<GenerateApiKeyHandle, GenerateApiKeyProps>(
onSuccess: () => {
setIsConfirmGenerateKeyModalOpen(false)
setIsGenerateKeyModalOpen(true)
queryClient.invalidateQueries({ queryKey: ['apiKeys', 'stats'] })
queryClient.refetchQueries({ queryKey: ['apiKeys', 'stats'] })
queryClient.invalidateQueries({ queryKey: ['devices'] })
// Three separate caches, so three separate invalidations. This was
// written as a single ['apiKeys', 'stats'] key, which react-query
// matches by prefix and which no query uses, so it matched neither
// the key list (['apiKeys', status]) nor the stats (['stats']):
// generating a key refreshed nothing.
queryClient.invalidateQueries({ queryKey: queryKeys.apiKeysAll })
queryClient.invalidateQueries({ queryKey: queryKeys.stats })
queryClient.invalidateQueries({ queryKey: queryKeys.devices })
},
mutationFn: () =>
httpBrowserClient

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { queryKeys } from '@/lib/api/query-keys'
import {
STEPS,
computeStepStates,
@@ -23,9 +24,12 @@ export type OnboardingStatus =
| 'hidden'
| 'celebrate'
// Data + derived state for the onboarding card. Query keys intentionally match
// the app-wide keys (['whoAmI'], ['stats'], ['currentSubscription']) so this
// hook shares caches and invalidations with the other dashboard components.
// Data + derived state for the onboarding card. Keys come from the shared
// queryKeys factory so this hook shares caches and invalidations with the
// other dashboard components. They were previously written out by hand, and
// the user key was spelled ['whoAmI'] here while the typed useCurrentUser
// hook used ['currentUser'], so the same endpoint was cached twice and
// invalidating either one left the other stale.
export function useOnboarding() {
const queryClient = useQueryClient()
const autoCompletedRef = useRef(false)
@@ -38,7 +42,7 @@ export function useOnboarding() {
const prevCompletedAtRef = useRef<string | Date | null | undefined>(undefined)
const userQuery = useQuery({
queryKey: ['whoAmI'],
queryKey: queryKeys.currentUser,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.whoAmI())
@@ -51,7 +55,7 @@ export function useOnboarding() {
const userData = userQuery.data
const statsQuery = useQuery({
queryKey: ['stats'],
queryKey: queryKeys.stats,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.getStats())
@@ -61,7 +65,7 @@ export function useOnboarding() {
const stats = statsQuery.data
const subQuery = useQuery({
queryKey: ['currentSubscription'],
queryKey: queryKeys.subscription,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.billing.currentSubscription())
@@ -81,9 +85,9 @@ export function useOnboarding() {
.patch(ApiEndpoints.auth.updateOnboarding(), body)
.then((r) => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['whoAmI'] })
queryClient.invalidateQueries({ queryKey: ['stats'] })
queryClient.invalidateQueries({ queryKey: ['currentSubscription'] })
queryClient.invalidateQueries({ queryKey: queryKeys.currentUser })
queryClient.invalidateQueries({ queryKey: queryKeys.stats })
queryClient.invalidateQueries({ queryKey: queryKeys.subscription })
},
})

View File

@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
import { ApiEndpoints } from '@/config/api'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { useQuery } from '@tanstack/react-query'
import { queryKeys } from '@/lib/api/query-keys'
import Link from 'next/link'
import { useMemo } from 'react'
import { Mail, ShieldAlert } from 'lucide-react'
@@ -13,7 +14,7 @@ export default function VerifyEmailAlert() {
isLoading: isLoadingUserData,
error: userDataError,
} = useQuery({
queryKey: ['whoAmI'],
queryKey: queryKeys.currentUser,
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.whoAmI())

View File

@@ -0,0 +1,113 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { QueryClient } from '@tanstack/react-query'
import { server } from '@/test/msw/server'
import { TestProviders } from '@/test/render'
import { API_BASE_URL, mockUser } from '@/test/fixtures'
import { ApiEndpoints } from '@/config/api'
import { queryKeys } from './query-keys'
import VerifyEmailAlert from '@/app/(app)/dashboard/(components)/verify-email-alert'
import GenerateApiKey from '@/app/(app)/dashboard/(components)/generate-api-key'
const url = (path: string) => `${API_BASE_URL}${path.split('?')[0]}`
/**
* These cover cache bugs that are invisible on inspection: the code looks
* correct at every individual call site, and only the relationship between
* sites is wrong. Both shipped to production.
*/
describe('cache invalidation', () => {
let queryClient: QueryClient
beforeEach(() => {
// Not makeTestQueryClient: it sets gcTime 0, which collects any cache
// entry the moment it has no observer, so priming a cache to assert it
// gets invalidated would never survive to be asserted on.
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
})
const renderWith = (ui: React.ReactElement) =>
render(<TestProviders queryClient={queryClient}>{ui}</TestProviders>)
// /auth/who-am-i used to be cached under two keys, ['whoAmI'] and
// ['currentUser'], so invalidating one left the other serving stale data.
// A user who verified their email still saw the "verify your email" banner,
// because the code that refreshed the user used the other key.
it('one invalidation refreshes every consumer of the current user', async () => {
let verified = false
server.use(
http.get(url(ApiEndpoints.auth.whoAmI()), () =>
HttpResponse.json({
data: {
...mockUser,
emailVerifiedAt: verified ? new Date().toISOString() : null,
},
})
)
)
const { container } = renderWith(<VerifyEmailAlert />)
// Selected by href, not by label: the banner picks its CTA text at random
// from five variants, so a text matcher here would be flaky.
const banner = () => container.querySelector('a[href="/verify-email"]')
// Unverified: the banner is up.
await waitFor(() => expect(banner()).toBeInTheDocument())
// The user verifies elsewhere, and something invalidates the user query
// using the canonical key.
verified = true
await queryClient.invalidateQueries({ queryKey: queryKeys.currentUser })
// The banner must notice. Under the two-key split it never did.
await waitFor(() => expect(banner()).toBeNull())
})
// The generate handler invalidated ['apiKeys', 'stats'], a key no query uses.
// react-query matches by prefix, so it matched neither the key list
// (['apiKeys', 'active']) nor the dashboard stats (['stats']): generating a
// key refreshed nothing at all.
it('generating an API key refreshes both the key list and the stats', async () => {
server.use(
http.post(url(ApiEndpoints.auth.generateApiKey()), () =>
HttpResponse.json({ data: 'new-api-key' })
)
)
// Prime the two caches a new key must invalidate.
queryClient.setQueryData(queryKeys.apiKeys('active'), { data: [] })
queryClient.setQueryData(queryKeys.stats, { totalApiKeyCount: 0 })
renderWith(<GenerateApiKey />)
// The trigger and the dialog's confirm button carry the same label, so the
// confirm has to be scoped to the dialog.
await userEvent.click(
screen.getByRole('button', { name: /Generate API Key/i })
)
const confirmDialog = await screen.findByRole('dialog', {
name: /Create new API Key/i,
})
await userEvent.click(
within(confirmDialog).getByRole('button', { name: /Generate API Key/i })
)
await waitFor(() => {
expect(
queryClient.getQueryState(queryKeys.apiKeys('active'))?.isInvalidated,
'the API key list must be invalidated'
).toBe(true)
expect(
queryClient.getQueryState(queryKeys.stats)?.isInvalidated,
'the dashboard stats must be invalidated'
).toBe(true)
})
})
})

View File

@@ -13,6 +13,11 @@ export const queryKeys = {
billingPlans: ['billingPlans'] as const,
apiKeys: (status: ApiKeyStatusFilter = 'active') =>
['apiKeys', status] as const,
// Prefix covering every apiKeys list regardless of status filter. Use this
// to invalidate: a new or revoked key changes the active, revoked and all
// lists at once, and invalidating only apiKeys('active') leaves the other
// two serving stale data.
apiKeysAll: ['apiKeys'] as const,
deviceMessages: (deviceId: string, filters?: Record<string, unknown>) =>
filters
? (['messages', deviceId, filters] as const)

View File

@@ -12,6 +12,10 @@ export interface User {
avatar?: string | null
emailVerifiedAt?: string | null
onboardingCompletedAt?: string | null
// Set when the user asks for deletion, which starts a 7-day window. Real
// field on the user schema, and read by the account-deletion banner, but it
// was missing from this type.
accountDeletionRequestedAt?: string | null
}
export interface GatewayStats {