fix: keep recent projects visible during refresh

This commit is contained in:
777genius 2026-04-14 18:39:34 +03:00
parent 8a7c1a764b
commit 58c21f3d24
3 changed files with 192 additions and 17 deletions

View file

@ -7,6 +7,10 @@ import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNor
import { useShallow } from 'zustand/react/shallow';
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
import {
getRecentProjectsClientSnapshot,
loadRecentProjectsWithClientCache,
} from '../utils/recentProjectsClientCache';
import { useOpenRecentProject } from './useOpenRecentProject';
@ -51,27 +55,45 @@ export function useRecentProjectsSection(
openProjectPath: (projectPath: string) => Promise<void>;
selectProjectFolder: () => Promise<void>;
} {
const { globalTasks, globalTasksLoading, fetchAllTasks, teams } = useStore(
useShallow((state) => ({
globalTasks: state.globalTasks,
globalTasksLoading: state.globalTasksLoading,
fetchAllTasks: state.fetchAllTasks,
teams: state.teams,
}))
);
const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } =
useStore(
useShallow((state) => ({
globalTasks: state.globalTasks,
globalTasksInitialized: state.globalTasksInitialized,
globalTasksLoading: state.globalTasksLoading,
fetchAllTasks: state.fetchAllTasks,
teams: state.teams,
}))
);
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>([]);
const [loading, setLoading] = useState(true);
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
initialSnapshot?.projects ?? []
);
const [loading, setLoading] = useState(initialSnapshot == null);
const [error, setError] = useState<string | null>(null);
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const hasFetchedTasksRef = useRef(false);
const hasFetchedTasksRef = useRef(globalTasksInitialized);
const recentProjectsRef = useRef<DashboardRecentProject[]>(initialSnapshot?.projects ?? []);
const reload = useCallback(async (): Promise<void> => {
setLoading(true);
useEffect(() => {
recentProjectsRef.current = recentProjects;
}, [recentProjects]);
const reload = useCallback(async (options?: { force?: boolean }): Promise<void> => {
const hasVisibleProjects =
recentProjectsRef.current.length > 0 || getRecentProjectsClientSnapshot() != null;
if (!hasVisibleProjects) {
setLoading(true);
}
setError(null);
try {
const projects = await api.getDashboardRecentProjects();
const projects = await loadRecentProjectsWithClientCache(
() => api.getDashboardRecentProjects(),
options
);
setRecentProjects(projects);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
@ -81,17 +103,23 @@ export function useRecentProjectsSection(
}, []);
useEffect(() => {
void reload();
const snapshot = getRecentProjectsClientSnapshot();
if (snapshot && !snapshot.isStale) {
return;
}
void reload({ force: snapshot != null });
}, [reload]);
useEffect(() => {
if (recentProjects.length === 0 || hasFetchedTasksRef.current) {
if (recentProjects.length === 0 || hasFetchedTasksRef.current || globalTasksInitialized) {
hasFetchedTasksRef.current = hasFetchedTasksRef.current || globalTasksInitialized;
return;
}
hasFetchedTasksRef.current = true;
void fetchAllTasks();
}, [fetchAllTasks, recentProjects.length]);
}, [fetchAllTasks, globalTasksInitialized, recentProjects.length]);
useEffect(() => {
let cancelled = false;

View file

@ -0,0 +1,62 @@
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
let cachedProjects: DashboardRecentProject[] | null = null;
let cachedAt = 0;
let inFlightLoad: Promise<DashboardRecentProject[]> | null = null;
export interface RecentProjectsClientSnapshot {
projects: DashboardRecentProject[];
fetchedAt: number;
isStale: boolean;
}
export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null {
if (!cachedProjects) {
return null;
}
return {
projects: cachedProjects,
fetchedAt: cachedAt,
isStale: Date.now() - cachedAt > RECENT_PROJECTS_CLIENT_CACHE_TTL_MS,
};
}
export async function loadRecentProjectsWithClientCache(
loader: () => Promise<DashboardRecentProject[]>,
options?: { force?: boolean }
): Promise<DashboardRecentProject[]> {
const force = options?.force ?? false;
const snapshot = getRecentProjectsClientSnapshot();
if (!force && snapshot && !snapshot.isStale) {
return snapshot.projects;
}
if (inFlightLoad) {
return inFlightLoad;
}
const request = loader()
.then((projects) => {
cachedProjects = projects;
cachedAt = Date.now();
return projects;
})
.finally(() => {
if (inFlightLoad === request) {
inFlightLoad = null;
}
});
inFlightLoad = request;
return request;
}
export function __resetRecentProjectsClientCacheForTests(): void {
cachedProjects = null;
cachedAt = 0;
inFlightLoad = null;
}

View file

@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
__resetRecentProjectsClientCacheForTests,
getRecentProjectsClientSnapshot,
loadRecentProjectsWithClientCache,
} from '@features/recent-projects/renderer/utils/recentProjectsClientCache';
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
const project = (id: string): DashboardRecentProject => ({
id,
name: id,
primaryPath: `/tmp/${id}`,
associatedPaths: [`/tmp/${id}`],
primaryBranch: null,
providerIds: ['anthropic'],
updatedAt: '2026-04-14T12:00:00.000Z',
});
describe('recentProjectsClientCache', () => {
afterEach(() => {
__resetRecentProjectsClientCacheForTests();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('returns cached projects while the client cache is fresh', async () => {
const loader = vi.fn().mockResolvedValue([project('alpha')]);
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]);
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]);
expect(loader).toHaveBeenCalledTimes(1);
expect(getRecentProjectsClientSnapshot()?.projects).toEqual([project('alpha')]);
});
it('revalidates stale cache without dropping the previous snapshot', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z'));
const loader = vi
.fn<() => Promise<DashboardRecentProject[]>>()
.mockResolvedValueOnce([project('alpha')])
.mockResolvedValueOnce([project('beta')]);
await loadRecentProjectsWithClientCache(loader);
vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z'));
expect(getRecentProjectsClientSnapshot()).toMatchObject({
projects: [project('alpha')],
isStale: true,
});
await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual([
project('beta'),
]);
expect(loader).toHaveBeenCalledTimes(2);
expect(getRecentProjectsClientSnapshot()).toMatchObject({
projects: [project('beta')],
isStale: false,
});
});
it('deduplicates concurrent client refreshes', async () => {
let resolveLoader: ((projects: DashboardRecentProject[]) => void) | null = null;
const loader = vi.fn(
() =>
new Promise<DashboardRecentProject[]>((resolve) => {
resolveLoader = resolve;
})
);
const first = loadRecentProjectsWithClientCache(loader, { force: true });
const second = loadRecentProjectsWithClientCache(loader, { force: true });
expect(loader).toHaveBeenCalledTimes(1);
resolveLoader?.([project('alpha')]);
await expect(first).resolves.toEqual([project('alpha')]);
await expect(second).resolves.toEqual([project('alpha')]);
});
});