mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user