mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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)),
|
||||||
|
]);
|
||||||
|
};
|
||||||
@@ -270,11 +270,11 @@ vi.mock('../../../components/error-display/error-display', () => ({
|
|||||||
default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'no-error'),
|
default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'no-error'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../components/loading-ellipsis', () => ({
|
vi.mock('../../../components/loading-ellipsis/loading-ellipsis', () => ({
|
||||||
default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string),
|
default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../components/board-pagination', () => ({
|
vi.mock('../../../components/board-pagination/board-pagination', () => ({
|
||||||
default: ({ basePath, currentPage, totalPages }: { basePath: string; currentPage: number; totalPages: number }) =>
|
default: ({ basePath, currentPage, totalPages }: { basePath: string; currentPage: number; totalPages: number }) =>
|
||||||
createElement('div', { 'data-testid': 'board-pagination' }, `${basePath}:${currentPage}:${totalPages}`),
|
createElement('div', { 'data-testid': 'board-pagination' }, `${basePath}:${currentPage}:${totalPages}`),
|
||||||
}));
|
}));
|
||||||
@@ -283,12 +283,12 @@ vi.mock('../../../components/board-buttons/board-buttons', () => ({
|
|||||||
CatalogButton: ({ address }: { address?: string }) => createElement('div', { 'data-testid': 'catalog-button' }, address || 'catalog'),
|
CatalogButton: ({ address }: { address?: string }) => createElement('div', { 'data-testid': 'catalog-button' }, address || 'catalog'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../components/footer', () => ({
|
vi.mock('../../../components/footer/footer', () => ({
|
||||||
PageFooterDesktop: ({ firstRow }: { firstRow: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-desktop' }, firstRow),
|
PageFooterDesktop: ({ firstRow }: { firstRow: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-desktop' }, firstRow),
|
||||||
PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-mobile' }, children),
|
PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-mobile' }, children),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../post', () => ({
|
vi.mock('../../post/post', () => ({
|
||||||
Post: ({ post }: { post?: TestComment }) => createElement('div', { 'data-testid': 'post' }, post?.cid || post?.content || 'missing-post'),
|
Post: ({ post }: { post?: TestComment }) => createElement('div', { 'data-testid': 'post' }, post?.cid || post?.content || 'missing-post'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -957,6 +957,31 @@ describe('Board', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('waits to probe broader multiboard suggestions until the current time window is exhausted', async () => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
testState.feed = [
|
||||||
|
{ cid: 'recent-post', communityAddress: 'music-posting.eth', timestamp: now - 12 * 60 * 60 },
|
||||||
|
{ cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: now - 5 * 24 * 60 * 60 },
|
||||||
|
];
|
||||||
|
testState.feedState = 'fetching-ipns';
|
||||||
|
testState.hasMore = true;
|
||||||
|
|
||||||
|
await renderBoard({
|
||||||
|
boardProps: { viewType: 'all' },
|
||||||
|
initialEntry: '/all?t=24h',
|
||||||
|
routePath: '/all/*',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testState.feedOptionsCalls).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ newerThan: 7 * 24 * 60 * 60, communitiesLength: 0 }),
|
||||||
|
expect.objectContaining({ newerThan: 30 * 24 * 60 * 60, communitiesLength: 0 }),
|
||||||
|
expect.objectContaining({ newerThan: 365 * 24 * 60 * 60, communitiesLength: 0 }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[data-testid="expand-time-window-button"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('surfaces board load errors when the feed is empty', async () => {
|
it('surfaces board load errors when the feed is empty', async () => {
|
||||||
testState.community = {
|
testState.community = {
|
||||||
error: new Error('board failed'),
|
error: new Error('board failed'),
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ import { getPretextItemSizeFromElement, resolveFeedVirtualizationMode } from '..
|
|||||||
import { isFlashDirectory, isFlashDirectoryCode } from '../../lib/flash-tags';
|
import { isFlashDirectory, isFlashDirectoryCode } from '../../lib/flash-tags';
|
||||||
import ErrorDisplay from '../../components/error-display/error-display';
|
import ErrorDisplay from '../../components/error-display/error-display';
|
||||||
import FlashBoardTable from '../../components/flash-board-table/flash-board-table';
|
import FlashBoardTable from '../../components/flash-board-table/flash-board-table';
|
||||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
import LoadingEllipsis from '../../components/loading-ellipsis/loading-ellipsis';
|
||||||
import BoardPagination from '../../components/board-pagination';
|
import BoardPagination from '../../components/board-pagination/board-pagination';
|
||||||
import { CatalogButton } from '../../components/board-buttons/board-buttons';
|
import { CatalogButton } from '../../components/board-buttons/board-buttons';
|
||||||
import { PageFooterDesktop, PageFooterMobile } from '../../components/footer';
|
import { PageFooterDesktop, PageFooterMobile } from '../../components/footer/footer';
|
||||||
import { ModEmptyState } from '../../components/mod-empty-state';
|
import ModEmptyState from '../../components/mod-empty-state/mod-empty-state';
|
||||||
import { Post } from '../post';
|
import { Post } from '../post/post';
|
||||||
|
|
||||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||||
const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60;
|
const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60;
|
||||||
@@ -236,7 +236,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
timeFilterSeconds: multiboardTimeFilterSeconds,
|
timeFilterSeconds: multiboardTimeFilterSeconds,
|
||||||
expandTimeWindow,
|
expandTimeWindow,
|
||||||
});
|
});
|
||||||
const shouldProbeSuggestionFeeds = isVisible && isMultiboardView && typeof currentTimeFilterSeconds === 'number';
|
const shouldProbeSuggestionFeeds = isVisible && isMultiboardView && typeof currentTimeFilterSeconds === 'number' && feedState === 'succeeded' && !hasMore;
|
||||||
const shouldProbeWeeklyFeed = shouldProbeSuggestionFeeds && currentTimeFilterSeconds < WEEK_IN_SECONDS;
|
const shouldProbeWeeklyFeed = shouldProbeSuggestionFeeds && currentTimeFilterSeconds < WEEK_IN_SECONDS;
|
||||||
const shouldProbeMonthlyFeed = shouldProbeSuggestionFeeds && currentTimeFilterSeconds < MONTH_IN_SECONDS;
|
const shouldProbeMonthlyFeed = shouldProbeSuggestionFeeds && currentTimeFilterSeconds < MONTH_IN_SECONDS;
|
||||||
const shouldProbeYearlyFeed = shouldProbeSuggestionFeeds && currentTimeFilterSeconds < YEAR_IN_SECONDS;
|
const shouldProbeYearlyFeed = shouldProbeSuggestionFeeds && currentTimeFilterSeconds < YEAR_IN_SECONDS;
|
||||||
@@ -392,8 +392,8 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
[effectiveInfiniteScroll, combinedFeed, guiPostsPerPage, maxGuiPages],
|
[effectiveInfiniteScroll, combinedFeed, guiPostsPerPage, maxGuiPages],
|
||||||
);
|
);
|
||||||
const moreThreadsSuggestion = useMemo(
|
const moreThreadsSuggestion = useMemo(
|
||||||
() => (isMultiboardView ? getTimeFilterSuggestion(feed.length, weeklyFeed.length, monthlyFeed.length, yearlyFeed.length, currentTimeFilterSeconds) : null),
|
() => (shouldProbeSuggestionFeeds ? getTimeFilterSuggestion(feed.length, weeklyFeed.length, monthlyFeed.length, yearlyFeed.length, currentTimeFilterSeconds) : null),
|
||||||
[currentTimeFilterSeconds, feed.length, isMultiboardView, monthlyFeed.length, weeklyFeed.length, yearlyFeed.length],
|
[currentTimeFilterSeconds, feed.length, monthlyFeed.length, shouldProbeSuggestionFeeds, weeklyFeed.length, yearlyFeed.length],
|
||||||
);
|
);
|
||||||
const moreThreadsSuggestionPathname = isInAllView ? '/all' : isInSubscriptionsView ? '/subs' : isInModView ? '/mod' : null;
|
const moreThreadsSuggestionPathname = isInAllView ? '/all' : isInSubscriptionsView ? '/subs' : isInModView ? '/mod' : null;
|
||||||
const registerComments = usePostNumberStore((state) => state.registerComments);
|
const registerComments = usePostNumberStore((state) => state.registerComments);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type TestComment = {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
replyCount?: number;
|
replyCount?: number;
|
||||||
replies?: unknown[];
|
replies?: unknown[];
|
||||||
|
refresh?: () => Promise<void>;
|
||||||
state?: string;
|
state?: string;
|
||||||
communityAddress?: string;
|
communityAddress?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
@@ -59,6 +60,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
},
|
},
|
||||||
} as { roles?: Record<string, unknown> },
|
} as { roles?: Record<string, unknown> },
|
||||||
useCommentCalls: [] as Array<{ commentCid?: string; autoUpdate?: boolean; community?: { name?: string; publicKey?: string } }>,
|
useCommentCalls: [] as Array<{ commentCid?: string; autoUpdate?: boolean; community?: { name?: string; publicKey?: string } }>,
|
||||||
|
evictThreadRefreshCachesMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('react-i18next', () => ({
|
vi.mock('react-i18next', () => ({
|
||||||
@@ -130,7 +132,7 @@ vi.mock('../../../components/error-display/error-display', () => ({
|
|||||||
default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'no-error'),
|
default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'no-error'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../components/footer', () => ({
|
vi.mock('../../../components/footer/footer', () => ({
|
||||||
PageFooterDesktop: ({ firstRow, styleRow }: { firstRow: React.ReactNode; styleRow: React.ReactNode }) =>
|
PageFooterDesktop: ({ firstRow, styleRow }: { firstRow: React.ReactNode; styleRow: React.ReactNode }) =>
|
||||||
createElement('div', { 'data-testid': 'page-footer-desktop' }, firstRow, styleRow),
|
createElement('div', { 'data-testid': 'page-footer-desktop' }, firstRow, styleRow),
|
||||||
ThreadFooterFirstRow: ({
|
ThreadFooterFirstRow: ({
|
||||||
@@ -158,7 +160,7 @@ vi.mock('../../../components/footer', () => ({
|
|||||||
ThreadFooterStyleRow: () => createElement('div', { 'data-testid': 'thread-footer-style-row' }, 'thread-footer-style-row'),
|
ThreadFooterStyleRow: () => createElement('div', { 'data-testid': 'thread-footer-style-row' }, 'thread-footer-style-row'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../components/post-desktop', () => ({
|
vi.mock('../../../components/post-desktop/post-desktop', () => ({
|
||||||
default: ({
|
default: ({
|
||||||
post,
|
post,
|
||||||
roles,
|
roles,
|
||||||
@@ -185,7 +187,7 @@ vi.mock('../../../components/post-desktop', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../components/post-mobile', () => ({
|
vi.mock('../../../components/post-mobile/post-mobile', () => ({
|
||||||
default: ({
|
default: ({
|
||||||
post,
|
post,
|
||||||
roles,
|
roles,
|
||||||
@@ -212,6 +214,10 @@ vi.mock('../../../components/post-mobile', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../lib/utils/thread-refresh-cache-utils', () => ({
|
||||||
|
evictThreadRefreshCaches: testState.evictThreadRefreshCachesMock,
|
||||||
|
}));
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
|
|
||||||
@@ -254,6 +260,8 @@ describe('Post', () => {
|
|||||||
testState.resolvedCommunityAddress = 'music-posting.eth';
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
||||||
testState.repliesByCommentCid = {};
|
testState.repliesByCommentCid = {};
|
||||||
testState.useCommentCalls = [];
|
testState.useCommentCalls = [];
|
||||||
|
testState.evictThreadRefreshCachesMock.mockReset();
|
||||||
|
testState.evictThreadRefreshCachesMock.mockResolvedValue(undefined);
|
||||||
useThreadLiveUpdatesStore.getState().resetState();
|
useThreadLiveUpdatesStore.getState().resetState();
|
||||||
testState.community = {
|
testState.community = {
|
||||||
error: undefined,
|
error: undefined,
|
||||||
@@ -737,4 +745,52 @@ describe('Post', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('evicts stale thread caches before refreshing a manual thread update', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const refreshReply = vi.fn(async () => {
|
||||||
|
events.push('refresh-reply');
|
||||||
|
});
|
||||||
|
const refreshPost = vi.fn(async () => {
|
||||||
|
events.push('refresh-post');
|
||||||
|
});
|
||||||
|
testState.evictThreadRefreshCachesMock.mockImplementation(async () => {
|
||||||
|
events.push('evict-cache');
|
||||||
|
});
|
||||||
|
testState.commentsByCid = {
|
||||||
|
'reply-cid': {
|
||||||
|
cid: 'reply-cid',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
parentCid: 'root-cid',
|
||||||
|
postCid: 'root-cid',
|
||||||
|
refresh: refreshReply,
|
||||||
|
},
|
||||||
|
'root-cid': {
|
||||||
|
cid: 'root-cid',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
number: 31,
|
||||||
|
postCid: 'root-cid',
|
||||||
|
refresh: refreshPost,
|
||||||
|
replyCount: 0,
|
||||||
|
title: 'Root thread',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderPostPage('/mu/thread/reply-cid');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useThreadLiveUpdatesStore.getState().requestUpdate();
|
||||||
|
});
|
||||||
|
await flushEffects();
|
||||||
|
|
||||||
|
expect(testState.evictThreadRefreshCachesMock).toHaveBeenCalledWith([testState.commentsByCid['reply-cid'], testState.commentsByCid['root-cid']]);
|
||||||
|
expect(events[0]).toBe('evict-cache');
|
||||||
|
expect(refreshReply).toHaveBeenCalledTimes(1);
|
||||||
|
expect(refreshPost).toHaveBeenCalledTimes(1);
|
||||||
|
expect(useThreadLiveUpdatesStore.getState()).toMatchObject({
|
||||||
|
isUpdating: false,
|
||||||
|
repliesResetRequestId: 1,
|
||||||
|
updateRequestId: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-4
@@ -13,10 +13,11 @@ import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-uti
|
|||||||
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import ErrorDisplay from '../../components/error-display/error-display';
|
import ErrorDisplay from '../../components/error-display/error-display';
|
||||||
import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer';
|
import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer/footer';
|
||||||
import PostDesktop from '../../components/post-desktop';
|
import PostDesktop from '../../components/post-desktop/post-desktop';
|
||||||
import PostMobile from '../../components/post-mobile';
|
import PostMobile from '../../components/post-mobile/post-mobile';
|
||||||
import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
||||||
|
import { evictThreadRefreshCaches } from '../../lib/utils/thread-refresh-cache-utils';
|
||||||
import { REPLIES_PER_PAGE } from '../../lib/constants';
|
import { REPLIES_PER_PAGE } from '../../lib/constants';
|
||||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||||
import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
|
import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
|
||||||
@@ -300,6 +301,7 @@ const PostPage = () => {
|
|||||||
const consumedThreadTopScrollRef = useRef<string | null>(null);
|
const consumedThreadTopScrollRef = useRef<string | null>(null);
|
||||||
const previousThreadCidRef = useRef<string>(undefined);
|
const previousThreadCidRef = useRef<string>(undefined);
|
||||||
const lastProcessedUpdateRequestIdRef = useRef(0);
|
const lastProcessedUpdateRequestIdRef = useRef(0);
|
||||||
|
const threadRefreshCommentsRef = useRef<Array<CommentWithRefresh | undefined>>([]);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -315,6 +317,7 @@ const PostPage = () => {
|
|||||||
// if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread
|
// if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread
|
||||||
const postComment = useCommentWithFeedCache({ commentCid: comment?.postCid, autoUpdate: autoUpdateEnabled, community: communityIdentifier });
|
const postComment = useCommentWithFeedCache({ commentCid: comment?.postCid, autoUpdate: autoUpdateEnabled, community: communityIdentifier });
|
||||||
const post = useMemo(() => (comment?.parentCid ? mergeCommentFallback(postComment, comment) : comment), [comment, postComment]);
|
const post = useMemo(() => (comment?.parentCid ? mergeCommentFallback(postComment, comment) : comment), [comment, postComment]);
|
||||||
|
threadRefreshCommentsRef.current = [comment, post];
|
||||||
const requestedThreadTopCid = getRequestedThreadTopCid(routeState);
|
const requestedThreadTopCid = getRequestedThreadTopCid(routeState);
|
||||||
|
|
||||||
const { error } = post || {};
|
const { error } = post || {};
|
||||||
@@ -419,7 +422,15 @@ const PostPage = () => {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
startUpdate();
|
startUpdate();
|
||||||
|
|
||||||
void Promise.allSettled(Array.from(refreshByCid.values(), (refresh) => refresh())).then((results) => {
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await evictThreadRefreshCaches(threadRefreshCommentsRef.current);
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.error('Failed to clear stale thread cache before refresh:', cacheError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.allSettled(Array.from(refreshByCid.values(), (refresh) => refresh()));
|
||||||
|
})().then((results) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const hasSuccessfulRefresh = results.some((result) => result.status === 'fulfilled');
|
const hasSuccessfulRefresh = results.some((result) => result.status === 'fulfilled');
|
||||||
|
|||||||
Reference in New Issue
Block a user