perf: pause hidden sidebar decoration refreshes (#2603)

This commit is contained in:
Neil 2026-05-21 22:46:01 -07:00 committed by GitHub
parent cb8596af6e
commit fdf293e4a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 230 additions and 39 deletions

View File

@ -45,6 +45,7 @@ import { writeWorkspaceDragData } from './workspace-status'
import { getWorktreeCardPrDisplay } from './worktree-card-pr-display'
import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups'
import { hasActiveWorkspaceActivity } from '@/lib/worktree-activity-state'
import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval'
import { runWorktreeDelete } from './delete-worktree-flow'
import { runSleepWorktree } from './sleep-worktree-flow'
import { getWorkspaceQuickActionKind } from './worktree-card-quick-action'
@ -264,10 +265,16 @@ const WorktreeCard = React.memo(function WorktreeCard({
if (isWebClient()) {
return
}
if (repo && !isFolder && !worktree.isBare && hostedReviewCacheKey && showPR) {
if (!repo || isFolder || worktree.isBare || !hostedReviewCacheKey || !showPR) {
return
}
const refreshHostedReviewIfVisible = (): void => {
if (!isWindowVisible()) {
return
}
// Why: branch lookup is lossy for fork/deleted-head PRs; reuse a known PR
// number from metadata or the visible cache whenever we have one.
fetchHostedReviewForBranch(repo.path, branch, {
void fetchHostedReviewForBranch(repo.path, branch, {
repoId: repo.id,
linkedGitHubPR: worktree.linkedPR ?? null,
fallbackGitHubPR: fallbackGitHubPRNumber,
@ -275,6 +282,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
staleWhileRevalidate: true
})
}
refreshHostedReviewIfVisible()
window.addEventListener('focus', refreshHostedReviewIfVisible)
document.addEventListener('visibilitychange', refreshHostedReviewIfVisible)
return () => {
window.removeEventListener('focus', refreshHostedReviewIfVisible)
document.removeEventListener('visibilitychange', refreshHostedReviewIfVisible)
}
}, [
repo,
isFolder,
@ -305,21 +319,35 @@ const WorktreeCard = React.memo(function WorktreeCard({
return
}
fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id })
const issueNumber = worktree.linkedIssue
// Background poll as fallback (activity triggers handle the fast path)
const interval = setInterval(() => {
fetchIssue(repo.path, worktree.linkedIssue!, { repoId: repo.id })
}, 5 * 60_000) // 5 minutes
return () => clearInterval(interval)
// Background poll as fallback (activity triggers handle the fast path).
// The interval itself is stopped while hidden so issue cards do not keep
// long-lived workspaces waking just to skip their fetch.
return installWindowVisibilityInterval({
run: () => void fetchIssue(repo.path, issueNumber, { repoId: repo.id }),
intervalMs: 5 * 60_000
})
}, [repo, isFolder, worktree.linkedIssue, fetchIssue, issueCacheKey, showIssue])
useEffect(() => {
if (!worktree.linkedLinearIssue || !showIssue) {
return
}
void fetchLinearIssue(worktree.linkedLinearIssue)
const linearIssueId = worktree.linkedLinearIssue
const refreshLinearIssueIfVisible = (): void => {
if (!isWindowVisible()) {
return
}
void fetchLinearIssue(linearIssueId)
}
refreshLinearIssueIfVisible()
window.addEventListener('focus', refreshLinearIssueIfVisible)
document.addEventListener('visibilitychange', refreshLinearIssueIfVisible)
return () => {
window.removeEventListener('focus', refreshLinearIssueIfVisible)
document.removeEventListener('visibilitychange', refreshLinearIssueIfVisible)
}
}, [worktree.linkedLinearIssue, fetchLinearIssue, showIssue])
// Stable click handler ignore clicks that are really text selections.

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import {
getExternalWorkspacePorts,
getWorkspacePortGroups,
getWorkspacePortsByWorktreeId
} from './workspace-port-groups'
describe('workspace port group caches', () => {
it('returns stable empty references when no scan result exists', () => {
expect(getWorkspacePortsByWorktreeId(null)).toBe(getWorkspacePortsByWorktreeId(undefined))
expect(getWorkspacePortGroups(null)).toBe(getWorkspacePortGroups(undefined))
expect(getExternalWorkspacePorts(null)).toBe(getExternalWorkspacePorts(undefined))
})
})

View File

@ -10,6 +10,9 @@ export type WorkspacePortGroup = {
const portsByWorktreeCache = new WeakMap<WorkspacePortScanResult, Map<string, WorkspacePort[]>>()
const workspaceGroupsCache = new WeakMap<WorkspacePortScanResult, WorkspacePortGroup[]>()
const externalPortsCache = new WeakMap<WorkspacePortScanResult, WorkspacePort[]>()
const EMPTY_PORTS_BY_WORKTREE = new Map<string, WorkspacePort[]>()
const EMPTY_WORKSPACE_PORT_GROUPS: WorkspacePortGroup[] = []
const EMPTY_EXTERNAL_PORTS: WorkspacePort[] = []
function comparePorts(a: WorkspacePort, b: WorkspacePort): number {
return a.port - b.port || (a.processName ?? '').localeCompare(b.processName ?? '')
@ -18,14 +21,15 @@ function comparePorts(a: WorkspacePort, b: WorkspacePort): number {
export function getWorkspacePortsByWorktreeId(
scan: WorkspacePortScanResult | null | undefined
): Map<string, WorkspacePort[]> {
if (scan) {
const cached = portsByWorktreeCache.get(scan)
if (cached) {
return cached
}
if (!scan) {
return EMPTY_PORTS_BY_WORKTREE
}
const cached = portsByWorktreeCache.get(scan)
if (cached) {
return cached
}
const grouped = new Map<string, WorkspacePort[]>()
for (const port of scan?.ports ?? []) {
for (const port of scan.ports) {
if (port.kind !== 'workspace') {
continue
}
@ -39,23 +43,22 @@ export function getWorkspacePortsByWorktreeId(
for (const ports of grouped.values()) {
ports.sort(comparePorts)
}
if (scan) {
portsByWorktreeCache.set(scan, grouped)
}
portsByWorktreeCache.set(scan, grouped)
return grouped
}
export function getWorkspacePortGroups(
scan: WorkspacePortScanResult | null | undefined
): WorkspacePortGroup[] {
if (scan) {
const cached = workspaceGroupsCache.get(scan)
if (cached) {
return cached
}
if (!scan) {
return EMPTY_WORKSPACE_PORT_GROUPS
}
const cached = workspaceGroupsCache.get(scan)
if (cached) {
return cached
}
const groupsByWorktreeId = new Map<string, WorkspacePortGroup>()
for (const port of scan?.ports ?? []) {
for (const port of scan.ports) {
if (port.kind !== 'workspace') {
continue
}
@ -78,24 +81,21 @@ export function getWorkspacePortGroups(
a.displayName.localeCompare(b.displayName) ||
(a.ports[0]?.port ?? 0) - (b.ports[0]?.port ?? 0)
)
if (scan) {
workspaceGroupsCache.set(scan, groups)
}
workspaceGroupsCache.set(scan, groups)
return groups
}
export function getExternalWorkspacePorts(
scan: WorkspacePortScanResult | null | undefined
): WorkspacePort[] {
if (scan) {
const cached = externalPortsCache.get(scan)
if (cached) {
return cached
}
if (!scan) {
return EMPTY_EXTERNAL_PORTS
}
const ports = (scan?.ports ?? []).filter((port) => port.kind !== 'workspace').sort(comparePorts)
if (scan) {
externalPortsCache.set(scan, ports)
const cached = externalPortsCache.get(scan)
if (cached) {
return cached
}
const ports = scan.ports.filter((port) => port.kind !== 'workspace').sort(comparePorts)
externalPortsCache.set(scan, ports)
return ports
}

View File

@ -2111,6 +2111,78 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => {
expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled()
})
it('does not fetch linked issue details when the issue card section is hidden', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
const worktreeId = 'wt-1'
store.setState({
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
groupBy: 'repo',
worktreeCardProperties: ['comment'],
rightSidebarOpen: false,
worktreesByRepo: {
'repo-1': [
{
id: worktreeId,
repoId: 'repo-1',
path: '/repo/worktrees/test',
branch,
displayName: 'test',
isMainWorktree: false,
isBare: false,
isArchived: false,
linkedIssue: 123
}
]
}
} as unknown as Partial<AppState>)
store.getState().refreshGitHubForWorktreeIfStale(worktreeId)
await Promise.resolve()
expect(mockApi.gh.issue).not.toHaveBeenCalled()
})
it('fetches linked issue details when the issue card section is visible', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
const worktreeId = 'wt-1'
store.setState({
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
groupBy: 'repo',
worktreeCardProperties: ['issue'],
rightSidebarOpen: false,
worktreesByRepo: {
'repo-1': [
{
id: worktreeId,
repoId: 'repo-1',
path: '/repo/worktrees/test',
branch,
displayName: 'test',
isMainWorktree: false,
isBare: false,
isArchived: false,
linkedIssue: 123
}
]
}
} as unknown as Partial<AppState>)
store.getState().refreshGitHubForWorktreeIfStale(worktreeId)
await Promise.resolve()
expect(mockApi.gh.issue).toHaveBeenCalledWith({
repoPath,
repoId: 'repo-1',
number: 123
})
})
it('enqueues active PR refresh IPC for connected SSH-backed repos', () => {
const store = createTestStore()
const repoPath = '/repo'
@ -2398,6 +2470,78 @@ describe('createGitHubSlice.refreshAllGitHub', () => {
timeoutMs: 30_000
})
})
it('does not refresh stale linked issues when the issue card section is hidden', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
store.setState({
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
groupBy: 'repo',
worktreeCardProperties: ['comment'],
rightSidebarOpen: false,
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/worktrees/test',
branch,
displayName: 'test',
isMainWorktree: false,
isBare: false,
isArchived: false,
lastActivityAt: 1,
linkedIssue: 123
}
]
}
} as unknown as Partial<AppState>)
store.getState().refreshAllGitHub()
await Promise.resolve()
expect(mockApi.gh.issue).not.toHaveBeenCalled()
})
it('refreshes stale linked issues when the issue card section is visible', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
store.setState({
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
groupBy: 'repo',
worktreeCardProperties: ['issue'],
rightSidebarOpen: false,
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/worktrees/test',
branch,
displayName: 'test',
isMainWorktree: false,
isBare: false,
isArchived: false,
lastActivityAt: 1,
linkedIssue: 123
}
]
}
} as unknown as Partial<AppState>)
store.getState().refreshAllGitHub()
await Promise.resolve()
expect(mockApi.gh.issue).toHaveBeenCalledWith({
repoPath,
repoId: 'repo-1',
number: 123
})
})
})
describe('createGitHubSlice.refreshGitHubForWorktree', () => {

View File

@ -930,6 +930,10 @@ function evictStaleEntries<T>(
return pruned
}
function shouldRefreshIssueDecorations(state: AppState): boolean {
return (state.worktreeCardProperties ?? []).includes('issue')
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
function debouncedSaveCache(state: AppState): void {
@ -2497,6 +2501,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const now = Date.now()
const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = []
const cardProps = state.worktreeCardProperties ?? []
const shouldRefreshIssues = shouldRefreshIssueDecorations(state)
const isPRStatusGrouping = state.groupBy === 'pr-status'
const rightSidebarShowsPR =
state.rightSidebarOpen &&
@ -2530,7 +2535,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
}
}
if (wt.linkedIssue) {
if (shouldRefreshIssues && wt.linkedIssue) {
const issueKey = repoScopedCacheKey(repo.path, repo.id, String(wt.linkedIssue))
const issueEntry = state.issueCache[issueKey]
if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) {
@ -2612,7 +2617,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
}
}
if (worktree.linkedIssue) {
if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) {
void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id })
}
},
@ -2795,7 +2800,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
}
if (worktree.linkedIssue) {
if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) {
const issueKey = repoScopedCacheKey(repo.path, repo.id, String(worktree.linkedIssue))
const issueEntry = state.issueCache[issueKey]
if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) {