perf(directories): dedupe refreshes across hook mounts

This commit is contained in:
Tommaso Casaburi
2026-05-10 12:21:27 +07:00
parent 1b4f412906
commit 5fe653726d
2 changed files with 109 additions and 18 deletions
@@ -92,6 +92,11 @@ const renderHarness = (address?: string) => {
}); });
}; };
const expectLatestSnapshot = (): Snapshot => {
expect(latestSnapshot).not.toBeNull();
return latestSnapshot as Snapshot;
};
describe('use-directories', () => { describe('use-directories', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -236,6 +241,47 @@ describe('use-directories', () => {
expect(persisted.communities).toHaveLength(2); expect(persisted.communities).toHaveLength(2);
}); });
it('does not refetch GitHub directories for each later hook mount after a successful refresh', async () => {
const remotePayload = {
title: 'Fresh directories',
description: 'fresh description',
createdAt: 3,
updatedAt: 4,
directories: [
{
name: 'music-posting.bso',
publicKey: 'music-public-key',
title: '/mu/ - Music',
directoryCode: 'mu',
features: { safeForWork: true, postsPerPage: 25 },
},
],
};
fetchMock.mockResolvedValueOnce(createFetchResponse(remotePayload));
renderHarness();
await flushEffects(8);
const firstSnapshot = expectLatestSnapshot();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(firstSnapshot.directories.map((community) => community.address)).toEqual(['music-posting.bso']);
expect(firstSnapshot.metadata?.title).toBe('Fresh directories');
latestSnapshot = null;
act(() => {
root.render(null);
});
renderHarness();
await flushEffects(8);
const secondSnapshot = expectLatestSnapshot();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(secondSnapshot.directories.map((community) => community.address)).toEqual(['music-posting.bso']);
expect(secondSnapshot.metadata?.title).toBe('Fresh directories');
});
it('clears invalid recent cache entries and falls back to vendored data when GitHub refresh fails', async () => { it('clears invalid recent cache entries and falls back to vendored data when GitHub refresh fails', async () => {
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify({ title: 'broken cache' })); localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify({ title: 'broken cache' }));
localStorage.setItem(LOCALSTORAGE_TIMESTAMP_KEY, String(Date.now())); localStorage.setItem(LOCALSTORAGE_TIMESTAMP_KEY, String(Date.now()));
+58 -13
View File
@@ -48,10 +48,13 @@ const GITHUB_URL = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/
const LOCALSTORAGE_KEY = '5chan-directories-cache'; const LOCALSTORAGE_KEY = '5chan-directories-cache';
const LOCALSTORAGE_TIMESTAMP_KEY = '5chan-directories-cache-timestamp'; const LOCALSTORAGE_TIMESTAMP_KEY = '5chan-directories-cache-timestamp';
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
const FETCH_RETRY_DELAY_MS = 60 * 1000; // 1 minute
let cacheCommunities: DirectoryCommunity[] | null = null; let cacheCommunities: DirectoryCommunity[] | null = null;
let cacheMetadata: DirectoriesMetadata | null = null; let cacheMetadata: DirectoriesMetadata | null = null;
let inFlightGitHubFetch: Promise<DirectoriesData> | null = null; let inFlightGitHubFetch: Promise<DirectoriesData> | null = null;
let lastSuccessfulGitHubFetchAt: number | null = null;
let lastGitHubFetchAttemptAt: number | null = null;
const DIRECTORY_ALIAS_SUFFIXES = ['.bso', '.eth'] as const; const DIRECTORY_ALIAS_SUFFIXES = ['.bso', '.eth'] as const;
// Exposed for deterministic unit tests around module-level cache state. // Exposed for deterministic unit tests around module-level cache state.
@@ -59,6 +62,8 @@ export const __resetDirectoriesModuleStateForTests = () => {
cacheCommunities = null; cacheCommunities = null;
cacheMetadata = null; cacheMetadata = null;
inFlightGitHubFetch = null; inFlightGitHubFetch = null;
lastSuccessfulGitHubFetchAt = null;
lastGitHubFetchAttemptAt = null;
fallbackDirectoriesData = null; fallbackDirectoriesData = null;
}; };
@@ -305,6 +310,18 @@ const saveToLocalStorage = (data: DirectoriesData) => {
} }
}; };
const toDirectoriesMetadata = (data: DirectoriesData): DirectoriesMetadata => ({
title: data.title,
description: data.description,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
});
const hydrateModuleCaches = (data: DirectoriesData) => {
cacheCommunities = data.communities;
cacheMetadata = toDirectoriesMetadata(data);
};
const fetchDirectoriesFromGitHub = async (): Promise<DirectoriesData> => { const fetchDirectoriesFromGitHub = async (): Promise<DirectoriesData> => {
const response = await fetch(GITHUB_URL, { cache: 'no-cache' }); const response = await fetch(GITHUB_URL, { cache: 'no-cache' });
if (!response.ok) { if (!response.ok) {
@@ -314,17 +331,38 @@ const fetchDirectoriesFromGitHub = async (): Promise<DirectoriesData> => {
if (!data) { if (!data) {
throw new Error('Invalid directories payload'); throw new Error('Invalid directories payload');
} }
// Save successful fetch to localStorage hydrateModuleCaches(data);
lastSuccessfulGitHubFetchAt = Date.now();
saveToLocalStorage(data); saveToLocalStorage(data);
return data; return data;
}; };
const fetchDirectoriesFromGitHubDeduped = async (): Promise<DirectoriesData> => { const shouldRefreshFromGitHub = () => {
if (!inFlightGitHubFetch) { const now = Date.now();
if (lastSuccessfulGitHubFetchAt !== null && now - lastSuccessfulGitHubFetchAt < CACHE_MAX_AGE_MS) {
return false;
}
if (lastGitHubFetchAttemptAt !== null && now - lastGitHubFetchAttemptAt < FETCH_RETRY_DELAY_MS) {
return false;
}
return true;
};
const fetchDirectoriesFromGitHubDeduped = async (): Promise<DirectoriesData | null> => {
if (inFlightGitHubFetch) {
return inFlightGitHubFetch;
}
if (!shouldRefreshFromGitHub()) {
return null;
}
lastGitHubFetchAttemptAt = Date.now();
inFlightGitHubFetch = fetchDirectoriesFromGitHub().finally(() => { inFlightGitHubFetch = fetchDirectoriesFromGitHub().finally(() => {
inFlightGitHubFetch = null; inFlightGitHubFetch = null;
}); });
}
return inFlightGitHubFetch; return inFlightGitHubFetch;
}; };
@@ -366,9 +404,13 @@ export const useDirectories = () => {
} }
try { try {
// Always attempt a background refresh from GitHub to pick up list updates // Refresh from GitHub when the session cache is stale, without refetching for every hook mount.
const directories = await fetchDirectoriesFromGitHubDeduped(); const directories = await fetchDirectoriesFromGitHubDeduped();
if (directories) {
hydrateCommunities(directories); hydrateCommunities(directories);
} else if (!cacheCommunities) {
hydrateCommunities(getFallbackDirectoriesData());
}
} catch (e) { } catch (e) {
console.warn('Failed to fetch directories from GitHub:', e); console.warn('Failed to fetch directories from GitHub:', e);
// Keep each hook instance in sync even if a sibling hook populated the module cache first. // Keep each hook instance in sync even if a sibling hook populated the module cache first.
@@ -434,9 +476,13 @@ export const useDirectoriesState = () => {
} }
try { try {
// Always attempt a background refresh from GitHub to pick up list updates // Refresh from GitHub when the session cache is stale, without refetching for every hook mount.
const directories = await fetchDirectoriesFromGitHubDeduped(); const directories = await fetchDirectoriesFromGitHubDeduped();
if (directories) {
hydrateCommunities(directories); hydrateCommunities(directories);
} else if (!cacheCommunities) {
hydrateCommunities(getFallbackDirectoriesData());
}
} catch (e) { } catch (e) {
console.warn('Failed to fetch directories from GitHub:', e); console.warn('Failed to fetch directories from GitHub:', e);
// Keep each hook instance in sync even if a sibling hook populated the module cache first. // Keep each hook instance in sync even if a sibling hook populated the module cache first.
@@ -480,12 +526,7 @@ export const useDirectoriesMetadata = () => {
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
const hydrateMetadata = (data: DirectoriesData) => { const hydrateMetadata = (data: DirectoriesData) => {
const nextMetadata: DirectoriesMetadata = { const nextMetadata = toDirectoriesMetadata(data);
title: data.title,
description: data.description,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
};
cacheMetadata = nextMetadata; cacheMetadata = nextMetadata;
if (isMounted) { if (isMounted) {
setMetadata(nextMetadata); setMetadata(nextMetadata);
@@ -504,9 +545,13 @@ export const useDirectoriesMetadata = () => {
} }
try { try {
// Always attempt a background refresh from GitHub to pick up metadata updates // Refresh from GitHub when the session cache is stale, without refetching for every hook mount.
const directories = await fetchDirectoriesFromGitHubDeduped(); const directories = await fetchDirectoriesFromGitHubDeduped();
if (directories) {
hydrateMetadata(directories); hydrateMetadata(directories);
} else if (!cacheMetadata) {
hydrateMetadata(getFallbackDirectoriesData());
}
} catch (e) { } catch (e) {
console.warn('Failed to fetch directory metadata from GitHub:', e); console.warn('Failed to fetch directory metadata from GitHub:', e);
// Keep each hook instance in sync even if a sibling hook populated the module cache first. // Keep each hook instance in sync even if a sibling hook populated the module cache first.