Skip to content

Commit c1df7eb

Browse files
Merge pull request #692 from nazarli-shabnam/fix/stale-query-caches-591
fix(ui): invalidate token, installation and membership caches after their mutations, share keys and the org-login threshold
2 parents 5b2d788 + d4f94ce commit c1df7eb

15 files changed

Lines changed: 154 additions & 31 deletions

File tree

‎apps/ui/app/activity/page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export default function ActivityPage() {
6767
// Heatmap comes from the personal cockpit endpoint (no org membership needed); the same
6868
// resolved token works for it and the org-scoped calls below.
6969
const cockpitQuery = useQuery({
70-
queryKey: ["analytics.cockpit-heatmap", org],
70+
queryKey: ["analytics.cockpit", org],
7171
queryFn: () => api.analytics.cockpit(org, token),
7272
enabled: queriesEnabled,
7373
retry: false,

‎apps/ui/app/automation/page.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import { PageHeader } from "@/components/page-header"
1010
import { Warning, Key, CircleNotch, Play, CheckCircle, XCircle, CircleDashed } from "@phosphor-icons/react"
1111
import { api } from "@/lib/api/client"
1212
import { useActiveScope } from "@/lib/active-scope"
13-
import { shouldApplyResolvedToken } from "@/lib/token-resolve"
13+
import { hasOrgLogin, shouldApplyResolvedToken } from "@/lib/token-resolve"
14+
import { invalidateTokens } from "@/lib/query-invalidation"
1415
import { BarGroupChart } from "@/components/charts/bar-group-chart"
1516
import { BranchProtectionCard } from "@/components/automation/branch-protection-card"
1617
import { DependabotTriageCard } from "@/components/automation/dependabot-triage-card"
@@ -21,8 +22,6 @@ import { relativeTime } from "@/lib/format"
2122
import { orgRoleFor } from "@/lib/members-href"
2223
import type { InstallationMeta, MyOrgMembership, RunSummary, WorkflowSummary } from "@/lib/api/types"
2324

24-
// > 0, not > 1: valid GitHub org logins can be a single character.
25-
const MIN_OWNER_LEN_FOR_REPO_LOOKUP = 1
2625
// Pause after the last keystroke in the owner box before it drives network lookups.
2726
const OWNER_LOOKUP_DEBOUNCE_MS = 400
2827

@@ -119,7 +118,7 @@ export default function AutomationPage() {
119118
useEffect(() => {
120119
setResolvedFor("")
121120
// > 0, not > 2: valid GitHub org logins can be 1-2 characters.
122-
if (lookupOwner.length > 0) resolveMutation.mutate(lookupOwner)
121+
if (hasOrgLogin(lookupOwner)) resolveMutation.mutate(lookupOwner)
123122
// eslint-disable-next-line react-hooks/exhaustive-deps
124123
}, [lookupOwner])
125124

@@ -128,7 +127,7 @@ export default function AutomationPage() {
128127
// request (and cache an entry) per keystroke. Saving a token refetches this instead.
129128
queryKey: ["repos.list", lookupOwner],
130129
queryFn: () => api.repos.list(lookupOwner, token),
131-
enabled: lookupOwner.length >= MIN_OWNER_LEN_FOR_REPO_LOOKUP && resolvedFor === lookupOwner,
130+
enabled: hasOrgLogin(lookupOwner) && resolvedFor === lookupOwner,
132131
retry: false,
133132
})
134133
const repoOptions = reposListQuery.data?.repos ?? []
@@ -138,6 +137,7 @@ export default function AutomationPage() {
138137
mutationFn: () => api.tokens.upsert(owner.trim(), token.trim()),
139138
onSuccess: () => {
140139
setTokenSaved(true)
140+
invalidateTokens(queryClient)
141141
queryClient.invalidateQueries({ queryKey: ["repos.list", lookupOwner] })
142142
},
143143
})

‎apps/ui/app/automation/permissions/page.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { reviewUrl } from "@/components/permission-drift-notice"
99
import { api } from "@/lib/api/client"
1010
import { useActiveScope } from "@/lib/active-scope"
1111
import { relativeTime } from "@/lib/format"
12+
import { invalidateInstallations } from "@/lib/query-invalidation"
1213
import type { InstallationMeta } from "@/lib/api/types"
1314

1415
const formatPermissions = (perms: Record<string, string>) =>
@@ -32,7 +33,7 @@ export default function PermissionsPage() {
3233
scope?.kind === "org" ? { scope: "org", orgLogin: scope.login } : { scope: "me" },
3334
installationId,
3435
),
35-
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
36+
onSuccess: () => invalidateInstallations(queryClient),
3637
})
3738

3839
const installs = (installsQuery.data ?? []).filter((i) => i.installation_id != null)

‎apps/ui/app/invite/[token]/page.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useState } from "react"
44
import Link from "next/link"
55
import { useParams, useRouter } from "next/navigation"
6-
import { useMutation, useQuery } from "@tanstack/react-query"
6+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
77
import { PageHeader } from "@/components/page-header"
88
import { Button } from "@/components/ui/button"
99
import { Warning, CheckCircle, CircleNotch } from "@phosphor-icons/react"
@@ -13,6 +13,7 @@ import { useAuth } from "@/lib/auth-context"
1313
export default function InviteAcceptPage() {
1414
const params = useParams<{ token: string }>()
1515
const router = useRouter()
16+
const queryClient = useQueryClient()
1617
const { user, isLoading: authLoading } = useAuth()
1718
const token = params.token
1819

@@ -23,7 +24,11 @@ export default function InviteAcceptPage() {
2324

2425
const accept = useMutation({
2526
mutationFn: () => api.invitations.accept(token),
26-
onSuccess: () => router.push("/"),
27+
onSuccess: () => {
28+
// The new membership must show up in the sidebar/Overview immediately, not after the 30 s staleTime.
29+
queryClient.invalidateQueries({ queryKey: ["my-orgs"] })
30+
router.push("/")
31+
},
2732
})
2833

2934
// The backend 403s with this specific message for an unverified email -- distinct from an

‎apps/ui/app/pulls/page.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client"
22

33
import { useEffect, useState } from "react"
4-
import { useMutation, useQuery } from "@tanstack/react-query"
4+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
55
import { PageHeader } from "@/components/page-header"
66
import { Button } from "@/components/ui/button"
77
import { Skeleton } from "@/components/ui/skeleton"
@@ -17,6 +17,8 @@ import type { MyOrgMembership, PullSummary } from "@/lib/api/types"
1717
// No repo-count cap here; requests are fanned out in batches so large orgs don't fire
1818
// dozens of simultaneous requests.
1919
const REPO_BATCH_SIZE = 10
20+
// Same entries (and key) as the Repositories page rows, so visiting one warms the other.
21+
const REPO_PULLS_STALE_MS = 30_000
2022

2123
interface PullRow extends PullSummary {
2224
repo: string
@@ -25,6 +27,7 @@ interface PullRow extends PullSummary {
2527
type GroupBy = "repo" | "author"
2628

2729
export default function PullRequestsPage() {
30+
const queryClient = useQueryClient()
2831
const { scope } = useActiveScope()
2932
// Pulls are listed via /orgs/{org}/repos; a personal scope has no org to list.
3033
const org = scope?.kind === "org" ? scope.login : ""
@@ -70,8 +73,12 @@ export default function PullRequestsPage() {
7073
const batch = repoNames.slice(i, i + REPO_BATCH_SIZE)
7174
const results = await Promise.all(
7275
batch.map((repo) =>
73-
api.repos
74-
.pulls(org, org, repo, token)
76+
queryClient
77+
.fetchQuery({
78+
queryKey: ["repo-pulls", org, repo, token],
79+
queryFn: () => api.repos.pulls(org, org, repo, token),
80+
staleTime: REPO_PULLS_STALE_MS,
81+
})
7582
.then((r) => r.pulls.map((p) => ({ ...p, repo })))
7683
.catch((e: unknown) => {
7784
failedRepos.push(repo)

‎apps/ui/app/releases/page.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SectionError } from "@/components/section-error"
88
import { relativeTime } from "@/lib/format"
99
import { api } from "@/lib/api/client"
1010
import { useActiveScope } from "@/lib/active-scope"
11+
import { hasOrgLogin } from "@/lib/token-resolve"
1112

1213
const DAY_OPTIONS = [30, 90, 180] as const
1314

@@ -24,12 +25,12 @@ export default function ReleasesPage() {
2425
const resolveQuery = useQuery({
2526
queryKey: ["tokens.resolve", org],
2627
queryFn: () => api.tokens.resolve(org),
27-
enabled: org.trim().length > 2,
28+
enabled: hasOrgLogin(org),
2829
retry: false,
2930
})
3031

3132
const token = resolveQuery.data?.token ?? ""
32-
const queriesEnabled = org.trim().length > 2 && !resolveQuery.isLoading
33+
const queriesEnabled = hasOrgLogin(org) && !resolveQuery.isLoading
3334

3435
const releaseTimelineQuery = useQuery({
3536
queryKey: ["github.release-timeline", org, days],

‎apps/ui/app/repos/page.tsx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useEffect, useMemo, useRef, useState } from "react"
44
import Link from "next/link"
5-
import { useMutation, useQuery } from "@tanstack/react-query"
5+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
66
import { PageHeader } from "@/components/page-header"
77
import { EmptyStateNoAccount } from "@/components/empty-state"
88
import { Input } from "@/components/ui/input"
@@ -11,7 +11,8 @@ import { Skeleton } from "@/components/ui/skeleton"
1111
import { Warning, Key, CircleNotch, Lock, Star, GitPullRequest, ArrowSquareOut } from "@phosphor-icons/react"
1212
import { api } from "@/lib/api/client"
1313
import { useActiveScope } from "@/lib/active-scope"
14-
import { shouldApplyResolvedToken } from "@/lib/token-resolve"
14+
import { hasOrgLogin, shouldApplyResolvedToken } from "@/lib/token-resolve"
15+
import { invalidateTokens } from "@/lib/query-invalidation"
1516
import { MiniSparkline } from "@/components/charts/mini-sparkline"
1617
import { relativeTime } from "@/lib/format"
1718
import { useInView } from "@/lib/use-in-view"
@@ -192,6 +193,7 @@ function RepoRow({ org, repo, token }: { org: string; repo: RepoSummary; token:
192193
}
193194

194195
export default function ReposPage() {
196+
const queryClient = useQueryClient()
195197
const [owner, setOwner] = useState("")
196198
const [token, setToken] = useState("")
197199
const [tokenSaved, setTokenSaved] = useState(false)
@@ -244,13 +246,16 @@ export default function ReposPage() {
244246
useEffect(() => {
245247
setToken("")
246248
setTokenSaved(false)
247-
if (owner.trim().length > 2) resolveMutation.mutate(owner.trim())
249+
if (hasOrgLogin(owner)) resolveMutation.mutate(owner.trim())
248250
// eslint-disable-next-line react-hooks/exhaustive-deps
249251
}, [owner])
250252

251253
const saveTokenMutation = useMutation({
252254
mutationFn: () => api.tokens.upsert(owner.trim(), token.trim()),
253-
onSuccess: () => setTokenSaved(true),
255+
onSuccess: () => {
256+
setTokenSaved(true)
257+
invalidateTokens(queryClient)
258+
},
254259
})
255260

256261
// Frozen when "Load repositories" fires so later owner/token edits don't retarget rendered rows.

‎apps/ui/app/security/page.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import { api } from "@/lib/api/client"
1616
import { toCsv } from "@/lib/csv"
1717
import { downloadTextFile } from "@/lib/download"
1818
import { useActiveScope } from "@/lib/active-scope"
19-
import { shouldApplyResolvedToken } from "@/lib/token-resolve"
19+
import { hasOrgLogin, shouldApplyResolvedToken } from "@/lib/token-resolve"
20+
import { invalidateTokens } from "@/lib/query-invalidation"
2021
import { DonutChart } from "@/components/charts/donut-chart"
2122
import { AreaTimeChart } from "@/components/charts/area-time-chart"
2223
import { BarGroupChart } from "@/components/charts/bar-group-chart"
@@ -151,19 +152,22 @@ export default function SecurityPage() {
151152
useEffect(() => {
152153
setToken("")
153154
setTokenSaved(false)
154-
if (owner.trim().length > 2) resolveMutation.mutate(owner.trim())
155+
if (hasOrgLogin(owner)) resolveMutation.mutate(owner.trim())
155156
// eslint-disable-next-line react-hooks/exhaustive-deps
156157
}, [owner])
157158

158159
const saveTokenMutation = useMutation({
159160
mutationFn: () => api.tokens.upsert(owner.trim(), token.trim()),
160-
onSuccess: () => setTokenSaved(true),
161+
onSuccess: () => {
162+
setTokenSaved(true)
163+
invalidateTokens(queryClient)
164+
},
161165
})
162166

163167
const historyQuery = useQuery({
164168
queryKey: ["analytics.history", owner],
165169
queryFn: () => api.analytics.history(owner),
166-
enabled: owner.trim().length > 2,
170+
enabled: hasOrgLogin(owner),
167171
})
168172

169173
const scan = useMutation({

‎apps/ui/app/settings/page.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useAuth } from "@/lib/auth-context"
2020
import { THEMES, useTheme } from "@/lib/theme"
2121
import type { InstallationMeta, MyOrgMembership, SavedTokenMeta } from "@/lib/api/types"
2222
import { githubWebUrl } from "@/lib/github-web"
23+
import { invalidateInstallations, invalidateTokens } from "@/lib/query-invalidation"
2324

2425

2526
function ProfileSection() {
@@ -270,7 +271,7 @@ function ConnectedOrgsSection() {
270271
const [confirmRow, setConfirmRow] = useState<ConnectedInstallation | null>(null)
271272

272273
const personalQuery = useQuery<InstallationMeta[]>({
273-
queryKey: ["installations", "me"],
274+
queryKey: ["installations"],
274275
queryFn: () => api.installations.list(),
275276
})
276277
const membershipsQuery = useMyOrgMemberships()
@@ -279,7 +280,7 @@ function ConnectedOrgsSection() {
279280
// Only orgs the caller admins -- listForOrg is 403 for a plain member.
280281
const orgInstallQueries = useQueries({
281282
queries: adminOrgLogins.map((orgLogin) => ({
282-
queryKey: ["installations", "org", orgLogin],
283+
queryKey: ["installations.org", orgLogin],
283284
queryFn: () => api.installations.listForOrg(orgLogin),
284285
enabled: !membershipsQuery.isLoading,
285286
})),
@@ -307,7 +308,7 @@ function ConnectedOrgsSection() {
307308
return api.installations.remove(row.scope === "me" ? { scope: "me" } : { scope: "org", orgLogin: row.orgLogin }, row.installation_id)
308309
},
309310
onSuccess: (_data, row) => {
310-
queryClient.invalidateQueries({ queryKey: ["installations"] })
311+
invalidateInstallations(queryClient)
311312
setConfirmRow(null)
312313
toast.success(`Disconnected ${row.account_login}.`)
313314
},
@@ -450,7 +451,7 @@ function SavedTokensSection() {
450451
const upsert = useMutation({
451452
mutationFn: () => api.tokens.upsert(addOrg.trim(), addToken.trim(), addLabel.trim() || undefined),
452453
onSuccess: () => {
453-
qc.invalidateQueries({ queryKey: ["tokens"] })
454+
invalidateTokens(qc)
454455
setAddOrg("")
455456
setAddToken("")
456457
setAddLabel("")
@@ -476,7 +477,7 @@ function SavedTokensSection() {
476477
next.delete(org)
477478
return next
478479
}),
479-
onSuccess: () => qc.invalidateQueries({ queryKey: ["tokens"] }),
480+
onSuccess: () => invalidateTokens(qc),
480481
})
481482

482483
const canAdd = addOrg.trim().length > 0 && addToken.trim().length > 0

‎apps/ui/components/app-sidebar.tsx‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,21 @@ export function AppSidebar() {
254254
})
255255

256256
const healthDot = healthDotColor(cockpit?.latest_score)
257-
const lastSeenAt = typeof window !== "undefined" ? localStorage.getItem(ACTIVITY_LAST_SEEN_KEY) : null
258-
const unreadCount = (cockpit?.recent_events ?? []).filter(
259-
(e) => !lastSeenAt || e.created_at > lastSeenAt,
260-
).length
257+
// Read after mount (never during render: the server render has no localStorage, and it can throw
258+
// in private mode) and again on navigation, since the Activity page writes the stamp when opened.
259+
const [lastSeenAt, setLastSeenAt] = useState<string | null>(null)
260+
useEffect(() => {
261+
try {
262+
setLastSeenAt(localStorage.getItem(ACTIVITY_LAST_SEEN_KEY))
263+
} catch {
264+
setLastSeenAt(null)
265+
}
266+
}, [pathname])
267+
// On the Activity page itself everything is being seen right now.
268+
const unreadCount =
269+
pathname === "/activity"
270+
? 0
271+
: (cockpit?.recent_events ?? []).filter((e) => !lastSeenAt || e.created_at > lastSeenAt).length
261272

262273
useEffect(() => {
263274
if (!open) return

0 commit comments

Comments
 (0)