fix(thread-page): make thread auto updates opt-in (#1115)

* fix(thread-page): make thread auto updates opt-in

Wire `Auto` and `Update` to the same manual refresh path and cover the thread flow with an e2e harness.

* fix(thread-page): address PR review findings
This commit is contained in:
Tommaso Casaburi
2026-03-20 16:48:57 +08:00
committed by GitHub
parent ce2ad82c8c
commit 816281c607
13 changed files with 630 additions and 49 deletions
+38 -1
View File
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import PostPage, { Post } from '../post';
import useThreadLiveUpdatesStore from '../../../stores/use-thread-live-updates-store';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -48,6 +49,7 @@ const testState = vi.hoisted(() => ({
'0xmod': { role: 'admin' },
},
} as { roles?: Record<string, unknown> },
useCommentCalls: [] as Array<{ commentCid?: string; autoUpdate?: boolean }>,
}));
vi.mock('react-i18next', () => ({
@@ -65,7 +67,10 @@ vi.mock('react-router-dom', async () => {
});
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined),
useComment: ({ commentCid, autoUpdate }: { commentCid?: string; autoUpdate?: boolean }) => {
testState.useCommentCalls.push({ commentCid, autoUpdate });
return commentCid ? testState.commentsByCid[commentCid] : undefined;
},
useEditedComment: ({ comment }: { comment?: TestComment }) => ({
editedComment: comment?.cid ? testState.editedCommentsByCid[comment.cid] : undefined,
}),
@@ -196,6 +201,8 @@ describe('Post', () => {
testState.editedCommentsByCid = {};
testState.isMobile = false;
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.useCommentCalls = [];
useThreadLiveUpdatesStore.getState().resetState();
testState.community = {
error: undefined,
shortAddress: 'music-posting.eth',
@@ -440,4 +447,34 @@ describe('Post', () => {
expect(Array.from(container.querySelectorAll('[data-testid="error-display"]')).map((node) => node.textContent)).toEqual(['board failed', 'missing comment']);
expect(container.querySelector('[data-testid="thread-footer-first-row"]')).toBeNull();
});
it('uses frozen useComment subscriptions when thread auto updates are disabled', async () => {
testState.commentsByCid = {
'reply-cid': {
cid: 'reply-cid',
communityAddress: 'music-posting.eth',
parentCid: 'root-cid',
postCid: 'root-cid',
replyCount: 0,
timestamp: 2,
},
'root-cid': {
cid: 'root-cid',
communityAddress: 'music-posting.eth',
postCid: 'root-cid',
replyCount: 4,
timestamp: 1,
},
};
useThreadLiveUpdatesStore.getState().setEnabled(false);
await renderPostPage('/mu/thread/reply-cid');
expect(testState.useCommentCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ commentCid: 'reply-cid', autoUpdate: false }),
expect.objectContaining({ commentCid: 'root-cid', autoUpdate: false }),
]),
);
});
});
+76 -10
View File
@@ -16,18 +16,32 @@ import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFo
import PostDesktop from '../../components/post-desktop';
import PostMobile from '../../components/post-mobile';
import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import styles from './post.module.css';
type CommentWithRefresh = Comment & {
refresh?: () => Promise<void>;
state?: string;
error?: Error;
errors?: Error[];
};
// useComment may not return cached feed data immediately due to its updatedAt comparison logic.
// This hook falls back to the communities pages store (populated by useFeed) so content
// from the catalog appears instantly instead of going through a loading phase.
const useCommentWithFeedCache = (options: { commentCid: string | undefined }) => {
const useCommentWithFeedCache = (options: { commentCid: string | undefined; autoUpdate?: boolean }): CommentWithRefresh | undefined => {
const comment = useComment(options);
const cachedComment = useCommunitiesPagesStore((state) => state.comments[options?.commentCid || '']);
return useMemo(() => {
if (!cachedComment || comment?.timestamp) return comment;
return { ...cachedComment, state: comment?.state, error: comment?.error, errors: comment?.errors } as Comment;
return {
...cachedComment,
refresh: comment?.refresh,
state: comment?.state,
error: comment?.error,
errors: comment?.errors,
} as CommentWithRefresh;
}, [comment, cachedComment]);
};
@@ -134,13 +148,20 @@ const PostPage = () => {
const params = useParams();
const location = useLocation();
const { commentCid } = params;
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
const updateRequestId = useThreadLiveUpdatesStore((state) => state.updateRequestId);
const startUpdate = useThreadLiveUpdatesStore((state) => state.startUpdate);
const finishUpdate = useThreadLiveUpdatesStore((state) => state.finishUpdate);
const resetThreadLiveUpdates = useThreadLiveUpdatesStore((state) => state.resetState);
const resolvedCommunityAddress = useResolvedCommunityAddress();
const isInAllView = isAllView(location.pathname);
const comment = useCommentWithFeedCache({ commentCid });
const comment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled });
const commentCommunityAddress = getCommentCommunityAddress(comment);
const communityAddress = resolvedCommunityAddress ?? commentCommunityAddress;
const consumedThreadTopScrollRef = useRef<string | null>(null);
const previousThreadCidRef = useRef<string>();
const lastProcessedUpdateRequestIdRef = useRef(0);
const navigate = useNavigate();
useEffect(() => {
@@ -154,13 +175,8 @@ const PostPage = () => {
const directories = useDirectories();
// 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 });
let post: Comment;
if (comment.parentCid) {
post = postComment;
} else {
post = comment;
}
const postComment = useCommentWithFeedCache({ commentCid: comment?.postCid, autoUpdate: autoUpdateEnabled });
const post = comment?.parentCid ? postComment : comment;
const requestedThreadTopCid = getRequestedThreadTopCid(location.state);
const { error } = post || {};
@@ -212,6 +228,56 @@ const PostPage = () => {
const targetReplyCid = comment?.parentCid ? comment?.cid : undefined;
useEffect(() => {
return () => {
resetThreadLiveUpdates();
};
}, [resetThreadLiveUpdates]);
useEffect(() => {
if (!post?.cid) return;
if (previousThreadCidRef.current && previousThreadCidRef.current !== post.cid) {
lastProcessedUpdateRequestIdRef.current = 0;
consumedThreadTopScrollRef.current = null;
resetThreadLiveUpdates();
}
previousThreadCidRef.current = post.cid;
}, [post?.cid, resetThreadLiveUpdates]);
useEffect(() => {
if (!post?.cid || updateRequestId <= lastProcessedUpdateRequestIdRef.current) return;
const refreshByCid = new Map<string, () => Promise<void>>();
if (comment?.cid && typeof comment.refresh === 'function') {
refreshByCid.set(comment.cid, comment.refresh);
}
if (post?.cid && typeof post.refresh === 'function') {
refreshByCid.set(post.cid, post.refresh);
}
if (refreshByCid.size === 0) return;
lastProcessedUpdateRequestIdRef.current = updateRequestId;
let cancelled = false;
startUpdate();
void (async () => {
const results = await Promise.allSettled(Array.from(refreshByCid.values(), (refresh) => refresh()));
if (cancelled) return;
const hasSuccessfulRefresh = results.some((result) => result.status === 'fulfilled');
finishUpdate(updateRequestId, hasSuccessfulRefresh);
const rejectedResult = results.find((result) => result.status === 'rejected');
if (rejectedResult?.status === 'rejected') {
console.error('Failed to refresh thread comments:', rejectedResult.reason);
}
})();
return () => {
cancelled = true;
};
}, [comment?.cid, comment?.refresh, finishUpdate, post?.cid, post?.refresh, startUpdate, updateRequestId]);
return (
<div className={styles.content}>
{shouldShowPostError && (