mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
perf(directories): dedupe refreshes across hook mounts
This commit is contained in:
@@ -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()));
|
||||||
|
|||||||
@@ -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();
|
||||||
inFlightGitHubFetch = fetchDirectoriesFromGitHub().finally(() => {
|
if (lastSuccessfulGitHubFetchAt !== null && now - lastSuccessfulGitHubFetchAt < CACHE_MAX_AGE_MS) {
|
||||||
inFlightGitHubFetch = null;
|
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 = 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();
|
||||||
hydrateCommunities(directories);
|
if (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();
|
||||||
hydrateCommunities(directories);
|
if (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();
|
||||||
hydrateMetadata(directories);
|
if (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.
|
||||||
|
|||||||
Reference in New Issue
Block a user