fix(recent-projects): scope client cache by context
This commit is contained in:
parent
72633daa6e
commit
b46b53d667
4 changed files with 178 additions and 69 deletions
|
|
@ -47,7 +47,9 @@ export function createRecentProjectsFeature(deps: {
|
|||
return {
|
||||
listDashboardRecentProjects: async () => {
|
||||
const activeContext = deps.getActiveContext();
|
||||
const payload = await useCase.execute(`dashboard-recent-projects:${activeContext.id}`);
|
||||
const payload = await useCase.execute(
|
||||
`dashboard-recent-projects:${activeContext.type}:${activeContext.id}`
|
||||
);
|
||||
return normalizeDashboardRecentProjectsPayload(payload) ?? { projects: [], degraded: true };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import {
|
|||
import { useOpenRecentProject } from './useOpenRecentProject';
|
||||
|
||||
import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter';
|
||||
import type { TeamSummary } from '@shared/types';
|
||||
|
||||
const INITIAL_RECENT_PROJECTS = 11;
|
||||
const LOAD_MORE_STEP = 8;
|
||||
|
|
@ -70,6 +69,7 @@ export function useRecentProjectsSection(
|
|||
globalTasksLoading,
|
||||
fetchAllTasks,
|
||||
teams,
|
||||
activeContextId,
|
||||
provisioningRuns,
|
||||
currentProvisioningRunIdByTeam,
|
||||
provisioningSnapshotByTeam,
|
||||
|
|
@ -80,12 +80,16 @@ export function useRecentProjectsSection(
|
|||
globalTasksLoading: state.globalTasksLoading,
|
||||
fetchAllTasks: state.fetchAllTasks,
|
||||
teams: state.teams,
|
||||
activeContextId: state.activeContextId,
|
||||
provisioningRuns: state.provisioningRuns,
|
||||
currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam,
|
||||
provisioningSnapshotByTeam: state.provisioningSnapshotByTeam,
|
||||
}))
|
||||
);
|
||||
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
|
||||
const initialSnapshot = useMemo(
|
||||
() => getRecentProjectsClientSnapshot(activeContextId),
|
||||
[activeContextId]
|
||||
);
|
||||
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
|
||||
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
|
||||
initialSnapshot?.payload.projects ?? []
|
||||
|
|
@ -105,6 +109,7 @@ export function useRecentProjectsSection(
|
|||
const recentProjectsRef = useRef<DashboardRecentProject[]>(
|
||||
initialSnapshot?.payload.projects ?? []
|
||||
);
|
||||
const activeContextIdRef = useRef(activeContextId);
|
||||
const provisioningState = useMemo(
|
||||
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
|
||||
[currentProvisioningRunIdByTeam, provisioningRuns]
|
||||
|
|
@ -125,37 +130,67 @@ export function useRecentProjectsSection(
|
|||
recentProjectsRef.current = recentProjects;
|
||||
}, [recentProjects]);
|
||||
|
||||
const reload = useCallback(async (options?: { force?: boolean }): Promise<void> => {
|
||||
const hasVisibleProjects =
|
||||
recentProjectsRef.current.length > 0 || getRecentProjectsClientSnapshot() != null;
|
||||
useEffect(() => {
|
||||
activeContextIdRef.current = activeContextId;
|
||||
}, [activeContextId]);
|
||||
|
||||
if (!hasVisibleProjects) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await loadRecentProjectsWithClientCache(
|
||||
() => api.getDashboardRecentProjects(),
|
||||
options
|
||||
);
|
||||
setRecentProjects(payload.projects);
|
||||
setRecentProjectsDegraded(payload.degraded);
|
||||
setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0));
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const reload = useCallback(
|
||||
async (options?: { force?: boolean }): Promise<void> => {
|
||||
const requestContextId = activeContextId;
|
||||
const hasVisibleProjects =
|
||||
recentProjectsRef.current.length > 0 ||
|
||||
getRecentProjectsClientSnapshot(requestContextId) != null;
|
||||
|
||||
if (!hasVisibleProjects) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await loadRecentProjectsWithClientCache(
|
||||
requestContextId,
|
||||
() => api.getDashboardRecentProjects(),
|
||||
options
|
||||
);
|
||||
if (activeContextIdRef.current !== requestContextId) {
|
||||
return;
|
||||
}
|
||||
setRecentProjects(payload.projects);
|
||||
setRecentProjectsDegraded(payload.degraded);
|
||||
setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0));
|
||||
} catch (nextError) {
|
||||
if (activeContextIdRef.current !== requestContextId) {
|
||||
return;
|
||||
}
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
|
||||
} finally {
|
||||
if (activeContextIdRef.current === requestContextId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeContextId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const snapshot = getRecentProjectsClientSnapshot();
|
||||
const snapshot = getRecentProjectsClientSnapshot(activeContextId);
|
||||
if (snapshot) {
|
||||
setRecentProjects(snapshot.payload.projects);
|
||||
setRecentProjectsDegraded(snapshot.payload.degraded);
|
||||
setDegradedRefreshCount(snapshot.payload.degraded ? 1 : 0);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setRecentProjects([]);
|
||||
setRecentProjectsDegraded(false);
|
||||
setDegradedRefreshCount(0);
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
if (snapshot && !snapshot.isStale) {
|
||||
return;
|
||||
}
|
||||
|
||||
void reload({ force: snapshot != null });
|
||||
}, [reload]);
|
||||
}, [activeContextId, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recentProjectsDegraded) {
|
||||
|
|
@ -225,22 +260,21 @@ export function useRecentProjectsSection(
|
|||
});
|
||||
}, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]);
|
||||
|
||||
const decoratedCards = useMemo(
|
||||
() =>
|
||||
adaptRecentProjectsSection({
|
||||
projects: sortRecentProjectsByDisplayPriority(recentProjects),
|
||||
taskCountsByProject,
|
||||
activeTeamsByProject,
|
||||
tasksLoading: globalTasksLoading,
|
||||
}),
|
||||
[
|
||||
activeTeamsByProject,
|
||||
globalTasksLoading,
|
||||
openHistoryVersion,
|
||||
recentProjects,
|
||||
const decoratedCards = useMemo(() => {
|
||||
void openHistoryVersion;
|
||||
return adaptRecentProjectsSection({
|
||||
projects: sortRecentProjectsByDisplayPriority(recentProjects),
|
||||
taskCountsByProject,
|
||||
]
|
||||
);
|
||||
activeTeamsByProject,
|
||||
tasksLoading: globalTasksLoading,
|
||||
});
|
||||
}, [
|
||||
activeTeamsByProject,
|
||||
globalTasksLoading,
|
||||
openHistoryVersion,
|
||||
recentProjects,
|
||||
taskCountsByProject,
|
||||
]);
|
||||
|
||||
const filteredCards = useMemo(
|
||||
() => decoratedCards.filter((card) => matchesSearch(card.project, searchQuery)),
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
|
|||
const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 30_000;
|
||||
|
||||
let cachedPayload: DashboardRecentProjectsPayloadLike = null;
|
||||
let cachedKey: string | null = null;
|
||||
let cachedAt = 0;
|
||||
let inFlightLoad: Promise<DashboardRecentProjectsPayload> | null = null;
|
||||
let inFlightLoad: { key: string; promise: Promise<DashboardRecentProjectsPayload> } | null = null;
|
||||
|
||||
export interface RecentProjectsClientSnapshot {
|
||||
payload: DashboardRecentProjectsPayload;
|
||||
|
|
@ -18,7 +19,13 @@ export interface RecentProjectsClientSnapshot {
|
|||
isStale: boolean;
|
||||
}
|
||||
|
||||
export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null {
|
||||
export function getRecentProjectsClientSnapshot(
|
||||
cacheKey: string
|
||||
): RecentProjectsClientSnapshot | null {
|
||||
if (cachedKey !== cacheKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPayload = normalizeDashboardRecentProjectsPayload(cachedPayload);
|
||||
if (!normalizedPayload) {
|
||||
return null;
|
||||
|
|
@ -40,39 +47,44 @@ export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot
|
|||
}
|
||||
|
||||
export async function loadRecentProjectsWithClientCache(
|
||||
cacheKey: string,
|
||||
loader: () => Promise<DashboardRecentProjectsPayloadLike>,
|
||||
options?: { force?: boolean }
|
||||
): Promise<DashboardRecentProjectsPayload> {
|
||||
const force = options?.force ?? false;
|
||||
const snapshot = getRecentProjectsClientSnapshot();
|
||||
const snapshot = getRecentProjectsClientSnapshot(cacheKey);
|
||||
|
||||
if (!force && snapshot && !snapshot.isStale) {
|
||||
return snapshot.payload;
|
||||
}
|
||||
|
||||
if (inFlightLoad) {
|
||||
return inFlightLoad;
|
||||
if (inFlightLoad?.key === cacheKey) {
|
||||
return inFlightLoad.promise;
|
||||
}
|
||||
|
||||
const request = loader()
|
||||
.then((payloadLike) => {
|
||||
const normalizedPayload = normalizeDashboardRecentProjectsPayload(payloadLike);
|
||||
cachedPayload = normalizedPayload;
|
||||
cachedAt = Date.now();
|
||||
if (inFlightLoad?.key === cacheKey && inFlightLoad.promise === request) {
|
||||
cachedKey = normalizedPayload ? cacheKey : null;
|
||||
cachedPayload = normalizedPayload;
|
||||
cachedAt = Date.now();
|
||||
}
|
||||
return normalizedPayload ?? { projects: [], degraded: true };
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightLoad === request) {
|
||||
if (inFlightLoad?.promise === request) {
|
||||
inFlightLoad = null;
|
||||
}
|
||||
});
|
||||
|
||||
inFlightLoad = request;
|
||||
inFlightLoad = { key: cacheKey, promise: request };
|
||||
return request;
|
||||
}
|
||||
|
||||
export function __resetRecentProjectsClientCacheForTests(): void {
|
||||
cachedPayload = null;
|
||||
cachedKey = null;
|
||||
cachedAt = 0;
|
||||
inFlightLoad = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
__resetRecentProjectsClientCacheForTests,
|
||||
getRecentProjectsClientSnapshot,
|
||||
loadRecentProjectsWithClientCache,
|
||||
} from '@features/recent-projects/renderer/utils/recentProjectsClientCache';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
DashboardRecentProject,
|
||||
|
|
@ -33,6 +32,19 @@ const payload = (
|
|||
degraded: false,
|
||||
...overrides,
|
||||
});
|
||||
const LOCAL_CACHE_KEY = 'local';
|
||||
const SSH_CACHE_KEY = 'ssh-dev';
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe('recentProjectsClientCache', () => {
|
||||
afterEach(() => {
|
||||
|
|
@ -44,11 +56,15 @@ describe('recentProjectsClientCache', () => {
|
|||
it('returns cached projects while the client cache is fresh', async () => {
|
||||
const loader = vi.fn().mockResolvedValue(payload('alpha'));
|
||||
|
||||
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha'));
|
||||
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha'));
|
||||
await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
|
||||
payload('alpha')
|
||||
);
|
||||
await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
|
||||
payload('alpha')
|
||||
);
|
||||
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha'));
|
||||
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)?.payload).toEqual(payload('alpha'));
|
||||
});
|
||||
|
||||
it('revalidates stale cache without dropping the previous snapshot', async () => {
|
||||
|
|
@ -60,20 +76,20 @@ describe('recentProjectsClientCache', () => {
|
|||
.mockResolvedValueOnce(payload('alpha'))
|
||||
.mockResolvedValueOnce(payload('beta'));
|
||||
|
||||
await loadRecentProjectsWithClientCache(loader);
|
||||
await loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader);
|
||||
vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z'));
|
||||
|
||||
expect(getRecentProjectsClientSnapshot()).toMatchObject({
|
||||
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
|
||||
payload: payload('alpha'),
|
||||
isStale: true,
|
||||
});
|
||||
|
||||
await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual(
|
||||
payload('beta')
|
||||
);
|
||||
await expect(
|
||||
loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader, { force: true })
|
||||
).resolves.toEqual(payload('beta'));
|
||||
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
expect(getRecentProjectsClientSnapshot()).toMatchObject({
|
||||
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
|
||||
payload: payload('beta'),
|
||||
isStale: false,
|
||||
});
|
||||
|
|
@ -92,8 +108,8 @@ describe('recentProjectsClientCache', () => {
|
|||
})
|
||||
);
|
||||
|
||||
const first = loadRecentProjectsWithClientCache(loader, { force: true });
|
||||
const second = loadRecentProjectsWithClientCache(loader, { force: true });
|
||||
const first = loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader, { force: true });
|
||||
const second = loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader, { force: true });
|
||||
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
|
@ -111,24 +127,24 @@ describe('recentProjectsClientCache', () => {
|
|||
.fn<() => Promise<DashboardRecentProjectsPayload>>()
|
||||
.mockResolvedValueOnce(payload('alpha', { degraded: true }));
|
||||
|
||||
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(
|
||||
await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
|
||||
payload('alpha', { degraded: true })
|
||||
);
|
||||
|
||||
vi.setSystemTime(new Date('2026-04-14T12:00:01.000Z'));
|
||||
expect(getRecentProjectsClientSnapshot()).toMatchObject({
|
||||
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
|
||||
payload: payload('alpha', { degraded: true }),
|
||||
isStale: false,
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-04-14T12:00:20.000Z'));
|
||||
expect(getRecentProjectsClientSnapshot()).toMatchObject({
|
||||
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
|
||||
payload: payload('alpha', { degraded: true }),
|
||||
isStale: false,
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-04-14T12:00:31.000Z'));
|
||||
expect(getRecentProjectsClientSnapshot()).toMatchObject({
|
||||
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toMatchObject({
|
||||
payload: payload('alpha', { degraded: true }),
|
||||
isStale: true,
|
||||
});
|
||||
|
|
@ -139,7 +155,52 @@ describe('recentProjectsClientCache', () => {
|
|||
.fn<() => Promise<DashboardRecentProject[]>>()
|
||||
.mockResolvedValue([project('alpha')]);
|
||||
|
||||
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha'));
|
||||
expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha'));
|
||||
await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
|
||||
payload('alpha')
|
||||
);
|
||||
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)?.payload).toEqual(payload('alpha'));
|
||||
});
|
||||
|
||||
it('does not serve a cached payload across active context keys', async () => {
|
||||
const loader = vi
|
||||
.fn<() => Promise<DashboardRecentProjectsPayload>>()
|
||||
.mockResolvedValueOnce(payload('local-alpha'))
|
||||
.mockResolvedValueOnce(payload('ssh-beta'));
|
||||
|
||||
await expect(loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader)).resolves.toEqual(
|
||||
payload('local-alpha')
|
||||
);
|
||||
|
||||
expect(getRecentProjectsClientSnapshot(SSH_CACHE_KEY)).toBeNull();
|
||||
await expect(loadRecentProjectsWithClientCache(SSH_CACHE_KEY, loader)).resolves.toEqual(
|
||||
payload('ssh-beta')
|
||||
);
|
||||
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toBeNull();
|
||||
expect(getRecentProjectsClientSnapshot(SSH_CACHE_KEY)?.payload).toEqual(payload('ssh-beta'));
|
||||
});
|
||||
|
||||
it('does not reuse or cache an in-flight payload for a different context key', async () => {
|
||||
const localRequest = deferred<DashboardRecentProjectsPayload>();
|
||||
const sshRequest = deferred<DashboardRecentProjectsPayload>();
|
||||
const loader = vi
|
||||
.fn<() => Promise<DashboardRecentProjectsPayload>>()
|
||||
.mockReturnValueOnce(localRequest.promise)
|
||||
.mockReturnValueOnce(sshRequest.promise);
|
||||
|
||||
const localLoad = loadRecentProjectsWithClientCache(LOCAL_CACHE_KEY, loader, { force: true });
|
||||
const sshLoad = loadRecentProjectsWithClientCache(SSH_CACHE_KEY, loader, { force: true });
|
||||
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
|
||||
sshRequest.resolve(payload('ssh-beta'));
|
||||
await expect(sshLoad).resolves.toEqual(payload('ssh-beta'));
|
||||
expect(getRecentProjectsClientSnapshot(SSH_CACHE_KEY)?.payload).toEqual(payload('ssh-beta'));
|
||||
|
||||
localRequest.resolve(payload('local-alpha'));
|
||||
await expect(localLoad).resolves.toEqual(payload('local-alpha'));
|
||||
expect(getRecentProjectsClientSnapshot(LOCAL_CACHE_KEY)).toBeNull();
|
||||
expect(getRecentProjectsClientSnapshot(SSH_CACHE_KEY)?.payload).toEqual(payload('ssh-beta'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue