fix(thread update): refresh stale reply caches (#1160)

* fix(thread update): refresh stale reply caches

Evict the current thread comment and known reply-page cache entries before manual refresh so stale local data does not keep replies hidden. Also defer broader multiboard suggestion feeds until the current /all time window is exhausted to reduce renderer pressure.

* fix(thread update): evict alternate reply page caches

* fix(board): hide stale time-window suggestions
This commit is contained in:
Tommaso Casaburi
2026-06-08 14:44:38 +07:00
committed by GitHub
parent 6c660c01cd
commit c2b222c4d5
6 changed files with 299 additions and 19 deletions
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const testState = vi.hoisted(() => ({
commentsRemoveItemMock: vi.fn(),
repliesPagesRemoveItemMock: vi.fn(),
repliesPagesState: {
comments: {} as Record<string, unknown>,
repliesPages: {} as Record<string, { comments?: Array<{ cid?: string }>; nextCid?: string }>,
},
repliesPagesSetStateMock: vi.fn(),
}));
vi.mock('@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru', () => ({
default: {
createInstance: ({ name }: { name: string }) => {
if (name === 'bitsocialReactHooks-comments') {
return {
removeItem: testState.commentsRemoveItemMock,
};
}
return {
removeItem: testState.repliesPagesRemoveItemMock,
};
},
},
}));
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/replies-pages', () => ({
default: {
getState: () => testState.repliesPagesState,
setState: (updater: (state: typeof testState.repliesPagesState) => Partial<typeof testState.repliesPagesState>) => {
testState.repliesPagesSetStateMock(updater);
const nextState = updater(testState.repliesPagesState);
testState.repliesPagesState = {
...testState.repliesPagesState,
...nextState,
};
},
},
}));
import { evictThreadRefreshCaches } from '../thread-refresh-cache-utils';
describe('thread-refresh-cache-utils', () => {
beforeEach(() => {
testState.commentsRemoveItemMock.mockReset();
testState.repliesPagesRemoveItemMock.mockReset();
testState.repliesPagesSetStateMock.mockClear();
testState.repliesPagesState = {
comments: {
'reply-a': { cid: 'reply-a' },
'reply-b': { cid: 'reply-b' },
'reply-c': { cid: 'reply-c' },
'reply-d': { cid: 'reply-d' },
unrelated: { cid: 'unrelated' },
},
repliesPages: {
'page-old-1': { comments: [{ cid: 'reply-a' }], nextCid: 'page-old-2' },
'page-old-2': { comments: [{ cid: 'reply-b' }] },
'page-old-inline-next': { comments: [{ cid: 'reply-c' }] },
'page-empty-1': { comments: [{ cid: 'reply-d' }] },
unrelated: { comments: [{ cid: 'unrelated' }] },
},
};
});
it('evicts the current thread comment and its persisted reply page chain only', async () => {
await evictThreadRefreshCaches([
{
cid: 'thread-cid',
replies: {
pageCids: {
old: 'page-old-1',
empty: 'page-empty-1',
},
pages: {
old: {
comments: [{ cid: 'inline-reply' }],
nextCid: 'page-old-inline-next',
},
empty: {
comments: [],
},
},
},
},
undefined,
{
cid: 'thread-cid',
},
]);
expect(testState.commentsRemoveItemMock).toHaveBeenCalledOnce();
expect(testState.commentsRemoveItemMock).toHaveBeenCalledWith('thread-cid');
expect(testState.repliesPagesRemoveItemMock).toHaveBeenCalledTimes(4);
expect(testState.repliesPagesRemoveItemMock.mock.calls.map(([pageCid]) => pageCid).sort()).toEqual([
'page-empty-1',
'page-old-1',
'page-old-2',
'page-old-inline-next',
]);
expect(testState.repliesPagesState.repliesPages).toEqual({
unrelated: { comments: [{ cid: 'unrelated' }] },
});
expect(testState.repliesPagesState.comments).toEqual({
unrelated: { cid: 'unrelated' },
});
});
});
@@ -0,0 +1,79 @@
import type { Comment, RepliesPages } from '@bitsocial/bitsocial-react-hooks';
import repliesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/replies-pages';
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru';
const commentsDatabase = localForageLru.createInstance({ name: 'bitsocialReactHooks-comments' });
const repliesPagesDatabase = localForageLru.createInstance({ name: 'bitsocialReactHooks-repliesPages' });
const getReplyPageSortTypes = (comment: Comment): string[] => {
const pageCids = comment.replies?.pageCids && typeof comment.replies.pageCids === 'object' ? comment.replies.pageCids : {};
const pages = comment.replies?.pages && typeof comment.replies.pages === 'object' ? comment.replies.pages : {};
return [...new Set([...Object.keys(pageCids), ...Object.keys(pages)])];
};
const getPersistedReplyPageStartCids = (comment: Comment, sortType: string): string[] => {
const pageCids = new Set<string>();
const firstPageCid = comment.replies?.pageCids?.[sortType];
const preloadedNextCid = comment.replies?.pages?.[sortType]?.nextCid;
if (typeof firstPageCid === 'string') pageCids.add(firstPageCid);
if (typeof preloadedNextCid === 'string') pageCids.add(preloadedNextCid);
return [...pageCids];
};
const collectReplyPageCids = (comment: Comment, repliesPages: RepliesPages): string[] => {
const pageCids = new Set<string>();
for (const sortType of getReplyPageSortTypes(comment)) {
for (const startPageCid of getPersistedReplyPageStartCids(comment, sortType)) {
let pageCid: string | undefined = startPageCid;
while (pageCid && !pageCids.has(pageCid)) {
pageCids.add(pageCid);
pageCid = repliesPages[pageCid]?.nextCid;
}
}
}
return [...pageCids];
};
const removeReplyPagesFromStore = (pageCids: string[]) => {
repliesPagesStore.setState((state) => {
const repliesPages = { ...state.repliesPages };
const comments = { ...state.comments };
let changed = false;
for (const pageCid of pageCids) {
const page = repliesPages[pageCid];
if (!page) continue;
for (const comment of page.comments || []) {
if (comment?.cid && comments[comment.cid]) {
delete comments[comment.cid];
}
}
delete repliesPages[pageCid];
changed = true;
}
return changed ? { repliesPages, comments } : {};
});
};
export const evictThreadRefreshCaches = async (comments: Array<Comment | undefined>) => {
const commentsToRefresh = comments.filter((comment): comment is Comment => Boolean(comment?.cid));
if (commentsToRefresh.length === 0) return;
const commentCids = [...new Set(commentsToRefresh.map((comment) => comment.cid as string))];
const currentReplyPages = repliesPagesStore.getState().repliesPages;
const replyPageCids = [...new Set(commentsToRefresh.flatMap((comment) => collectReplyPageCids(comment, currentReplyPages)))];
removeReplyPagesFromStore(replyPageCids);
await Promise.all([
...commentCids.map((commentCid) => commentsDatabase.removeItem(commentCid)),
...replyPageCids.map((pageCid) => repliesPagesDatabase.removeItem(pageCid)),
]);
};