From 5da02b0105cb0bf17488ca31e72ca716a3beb53b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:27:59 -0700 Subject: [PATCH] Bound GitHub project picker browse cache (#7681) --- .../github-project/ProjectPicker.tsx | 23 ++-- .../project-picker-browse-cache.test.ts | 103 ++++++++++++++++++ .../project-picker-browse-cache.ts | 82 ++++++++++++++ 3 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/github-project/project-picker-browse-cache.test.ts create mode 100644 src/renderer/src/components/github-project/project-picker-browse-cache.ts diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx index de2f2710d..3e11b0094 100644 --- a/src/renderer/src/components/github-project/ProjectPicker.tsx +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -30,6 +30,11 @@ import { isGitHubProjectRefInputTooLarge } from '../../../../shared/github-project-ref-input' import { filterGitHubProjectPickerProjects } from './github-project-picker-filter' +import { + getProjectPickerBrowseCacheEntry, + peekProjectPickerBrowseCacheEntry, + rememberProjectPickerBrowseCacheEntry +} from './project-picker-browse-cache' import { translate } from '@/i18n/i18n' export type ResolvedProjectSelection = { @@ -49,15 +54,6 @@ type Props = { onSelect: (selection: ResolvedProjectSelection) => void } -const BROWSE_CACHE_TTL_MS = 5 * 60_000 -type BrowseCacheEntry = { - fetchedAt: number - projects: GitHubProjectSummary[] - partialFailures?: { owner: string; message: string }[] -} - -const browseCacheByRuntimeScope = new Map() - function getProjectPickerRuntimeScope( settings: Parameters[0] ): string { @@ -125,7 +121,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React const [query, setQuery] = useState('') const [browseLoading, setBrowseLoading] = useState(false) const [browseError, setBrowseError] = useState(null) - const browseCache = browseCacheByRuntimeScope.get(getProjectPickerRuntimeScope(settings)) + const browseCache = peekProjectPickerBrowseCacheEntry(getProjectPickerRuntimeScope(settings)) const [browseProjects, setBrowseProjects] = useState( () => browseCache?.projects ?? [] ) @@ -147,8 +143,8 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React const loadBrowse = useCallback(async () => { const cacheKey = getProjectPickerRuntimeScope(settings) - const cached = browseCacheByRuntimeScope.get(cacheKey) ?? null - if (cached && Date.now() - cached.fetchedAt < BROWSE_CACHE_TTL_MS) { + const cached = getProjectPickerBrowseCacheEntry(cacheKey) + if (cached) { setBrowseProjects(cached.projects) setPartialFailures(cached.partialFailures ?? []) return @@ -158,8 +154,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React try { const res = await listAccessibleProjectsForRuntime(settings) if (res.ok) { - browseCacheByRuntimeScope.set(cacheKey, { - fetchedAt: Date.now(), + rememberProjectPickerBrowseCacheEntry(cacheKey, { projects: res.projects, partialFailures: res.partialFailures }) diff --git a/src/renderer/src/components/github-project/project-picker-browse-cache.test.ts b/src/renderer/src/components/github-project/project-picker-browse-cache.test.ts new file mode 100644 index 000000000..bec4e8b61 --- /dev/null +++ b/src/renderer/src/components/github-project/project-picker-browse-cache.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { GitHubProjectSummary } from '../../../../shared/github-project-types' +import { + PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES, + PROJECT_PICKER_BROWSE_CACHE_TTL_MS, + _clearProjectPickerBrowseCacheForTest, + _getProjectPickerBrowseCacheSizeForTest, + getProjectPickerBrowseCacheEntry, + peekProjectPickerBrowseCacheEntry, + rememberProjectPickerBrowseCacheEntry +} from './project-picker-browse-cache' + +function project(scope: string): GitHubProjectSummary { + return { + id: `${scope}-project`, + owner: scope, + ownerType: 'organization', + number: 1, + title: `${scope} Roadmap`, + url: `https://github.com/orgs/${scope}/projects/1`, + source: 'viewer' + } +} + +describe('project-picker-browse-cache', () => { + beforeEach(() => { + _clearProjectPickerBrowseCacheForTest() + }) + + it('prunes expired scopes when a different runtime is read', () => { + rememberProjectPickerBrowseCacheEntry('runtime:old-1', { projects: [project('old-1')] }, 1_000) + rememberProjectPickerBrowseCacheEntry('runtime:old-2', { projects: [project('old-2')] }, 900) + rememberProjectPickerBrowseCacheEntry( + 'runtime:current', + { projects: [project('current')] }, + 2_000 + ) + + expect( + getProjectPickerBrowseCacheEntry( + 'runtime:current', + 1_000 + PROJECT_PICKER_BROWSE_CACHE_TTL_MS + ) + ).toMatchObject({ projects: [expect.objectContaining({ owner: 'current' })] }) + expect(_getProjectPickerBrowseCacheSizeForTest()).toBe(1) + }) + + it('stays bounded through prolonged churn while preserving a reused scope', () => { + let inserted = 1 + rememberProjectPickerBrowseCacheEntry( + 'runtime:retained', + { projects: [project('retained')] }, + 0 + ) + for (let wave = 0; wave < 4; wave += 1) { + expect( + getProjectPickerBrowseCacheEntry('runtime:retained', inserted) + ).toMatchObject({ projects: [expect.objectContaining({ owner: 'retained' })] }) + for (let index = 1; index < PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES; index += 1) { + const scope = `scope-${inserted}` + rememberProjectPickerBrowseCacheEntry( + `runtime:${scope}`, + { projects: [project(scope)] }, + inserted + ) + inserted += 1 + } + expect(_getProjectPickerBrowseCacheSizeForTest()).toBe( + PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES + ) + } + + expect(getProjectPickerBrowseCacheEntry('runtime:retained', inserted + 1)).toMatchObject({ + projects: [expect.objectContaining({ owner: 'retained' })] + }) + expect(getProjectPickerBrowseCacheEntry('runtime:scope-1', inserted + 1)).toBeNull() + }) + + it('peeks without changing cache recency during render', () => { + for (let index = 0; index < PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES; index += 1) { + rememberProjectPickerBrowseCacheEntry( + `runtime:${index}`, + { projects: [project(`runtime-${index}`)] }, + index + ) + } + + expect(peekProjectPickerBrowseCacheEntry('runtime:0', 40)).not.toBeNull() + rememberProjectPickerBrowseCacheEntry('runtime:new', { projects: [project('new')] }, 41) + + expect(getProjectPickerBrowseCacheEntry('runtime:0', 42)).toBeNull() + }) + + it('refreshes an existing scope without growing the cache', () => { + rememberProjectPickerBrowseCacheEntry('runtime:one', { projects: [project('old')] }, 1_000) + rememberProjectPickerBrowseCacheEntry('runtime:one', { projects: [project('new')] }, 1_001) + + expect(_getProjectPickerBrowseCacheSizeForTest()).toBe(1) + expect(getProjectPickerBrowseCacheEntry('runtime:one', 1_002)).toMatchObject({ + projects: [expect.objectContaining({ owner: 'new' })] + }) + }) +}) diff --git a/src/renderer/src/components/github-project/project-picker-browse-cache.ts b/src/renderer/src/components/github-project/project-picker-browse-cache.ts new file mode 100644 index 000000000..bd1726aec --- /dev/null +++ b/src/renderer/src/components/github-project/project-picker-browse-cache.ts @@ -0,0 +1,82 @@ +import type { GitHubProjectSummary } from '../../../../shared/github-project-types' + +export const PROJECT_PICKER_BROWSE_CACHE_TTL_MS = 5 * 60_000 +export const PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES = 32 + +type ProjectPickerBrowseCacheEntry = { + fetchedAt: number + projects: GitHubProjectSummary[] + partialFailures?: { owner: string; message: string }[] +} + +const browseCacheByRuntimeScope = new Map() + +function pruneExpiredProjectPickerBrowseCache(now: number): void { + for (const [key, entry] of browseCacheByRuntimeScope) { + if (now - entry.fetchedAt >= PROJECT_PICKER_BROWSE_CACHE_TTL_MS) { + browseCacheByRuntimeScope.delete(key) + } + } +} + +function trimProjectPickerBrowseCache(): void { + while (browseCacheByRuntimeScope.size > PROJECT_PICKER_BROWSE_CACHE_MAX_ENTRIES) { + const oldestKey = browseCacheByRuntimeScope.keys().next().value + if (oldestKey === undefined) { + return + } + browseCacheByRuntimeScope.delete(oldestKey) + } +} + +export function peekProjectPickerBrowseCacheEntry( + cacheKey: string, + now = Date.now() +): ProjectPickerBrowseCacheEntry | null { + const entry = browseCacheByRuntimeScope.get(cacheKey) + if (!entry || now - entry.fetchedAt >= PROJECT_PICKER_BROWSE_CACHE_TTL_MS) { + return null + } + return entry +} + +export function getProjectPickerBrowseCacheEntry( + cacheKey: string, + now = Date.now() +): ProjectPickerBrowseCacheEntry | null { + pruneExpiredProjectPickerBrowseCache(now) + const entry = peekProjectPickerBrowseCacheEntry(cacheKey, now) + if (!entry) { + browseCacheByRuntimeScope.delete(cacheKey) + return null + } + // Why: cache keys are runtime scopes; refresh recency so active runtimes do + // not get evicted just because a user briefly tries many other runtimes. + browseCacheByRuntimeScope.delete(cacheKey) + browseCacheByRuntimeScope.set(cacheKey, entry) + return entry +} + +export function rememberProjectPickerBrowseCacheEntry( + cacheKey: string, + entry: Omit, + now = Date.now() +): void { + pruneExpiredProjectPickerBrowseCache(now) + browseCacheByRuntimeScope.delete(cacheKey) + browseCacheByRuntimeScope.set(cacheKey, { + ...entry, + fetchedAt: now + }) + trimProjectPickerBrowseCache() +} + +/** @internal - exposed for leak-regression tests only */ +export function _getProjectPickerBrowseCacheSizeForTest(): number { + return browseCacheByRuntimeScope.size +} + +/** @internal - exposed for leak-regression tests only */ +export function _clearProjectPickerBrowseCacheForTest(): void { + browseCacheByRuntimeScope.clear() +}