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
@@ -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 { DesktopBoardButtons, MobileBoardButtons } from '../board-buttons';
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>;
@@ -237,6 +238,7 @@ describe('BoardButtons', () => {
testState.sortType = 'active';
testState.subscribed = false;
testState.viewMode = 'compact';
useThreadLiveUpdatesStore.getState().resetState();
Object.defineProperty(globalThis, 'alert', {
configurable: true,
value: vi.fn(),
@@ -321,7 +323,7 @@ describe('BoardButtons', () => {
expect(testState.resetMock).toHaveBeenCalledTimes(1);
});
it('renders thread actions and post stats, then updates, auto-alerts, and scrolls to the bottom', async () => {
it('renders thread actions and post stats, then requests refreshes, toggles auto updates, and scrolls to the bottom', async () => {
testState.commentsByCid = {
'comment-1': {
cid: 'comment-1',
@@ -345,11 +347,36 @@ describe('BoardButtons', () => {
await clickButton('bottom');
await clickButton('update');
await clickButton('Auto');
const autoCheckbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(autoCheckbox?.checked).toBe(false);
await act(async () => {
autoCheckbox?.click();
});
expect(window.scrollTo).toHaveBeenCalledWith({ behavior: 'instant', top: 2400 });
expect(testState.resetMock).toHaveBeenCalledTimes(1);
expect(globalThis.alert).toHaveBeenCalledWith('posts_auto_update_info');
expect(useThreadLiveUpdatesStore.getState().updateRequestId).toBe(1);
expect(useThreadLiveUpdatesStore.getState().enabled).toBe(true);
expect(autoCheckbox?.checked).toBe(true);
});
it('keeps the update button enabled while a manual thread refresh is in progress', async () => {
testState.commentsByCid = {
'comment-1': {
cid: 'comment-1',
postCid: 'comment-1',
replyCount: 9,
},
};
useThreadLiveUpdatesStore.getState().startUpdate();
await renderWithRoute(createElement(DesktopBoardButtons), '/mu/thread/comment-1');
const updateButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'update');
expect(updateButton?.hasAttribute('disabled')).toBe(false);
await clickButton('update');
expect(useThreadLiveUpdatesStore.getState().updateRequestId).toBe(1);
});
it('renders mobile mod-queue controls and clamps alert threshold updates', async () => {
+18 -24
View File
@@ -13,6 +13,7 @@ import useSortingStore from '../../stores/use-sorting-store';
import useAllFeedFilterStore from '../../stores/use-all-feed-filter-store';
import useModQueueStore from '../../stores/use-mod-queue-store';
import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import useIsMobile from '../../hooks/use-is-mobile';
import CatalogFilters from '../catalog-filters';
@@ -35,7 +36,6 @@ interface BoardButtonsProps {
export const CatalogButton = ({ address, isInAllView, isInSubscriptionsView, isInModView }: BoardButtonsProps) => {
const { t } = useTranslation();
const params = useParams();
const directories = useDirectories();
const createCatalogLink = () => {
@@ -158,9 +158,9 @@ export const RefreshButton = () => {
export const UpdateButton = () => {
const { t } = useTranslation();
const reset = useFeedResetStore((state) => state.reset);
const requestUpdate = useThreadLiveUpdatesStore((state) => state.requestUpdate);
return (
<button className='button' onClick={() => reset?.()}>
<button className='button' onClick={() => requestUpdate()}>
{t('update')}
</button>
);
@@ -169,26 +169,19 @@ export const UpdateButton = () => {
export const AutoButton = () => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const handleAutoClick = () => {
window.alert(t('posts_auto_update_info'));
};
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
const setAutoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.setEnabled);
return (
<>
{isMobile ? (
<button className='button' onClick={handleAutoClick}>
<label>
<input type='checkbox' className={styles.autoCheckbox} checked disabled />
{t('Auto')}
</label>
</button>
) : (
<label onClick={handleAutoClick}>
{' '}
<input type='checkbox' className={styles.autoCheckbox} checked disabled /> {t('Auto')}
</label>
)}
</>
<label className={isMobile ? 'button' : undefined}>
<input
type='checkbox'
aria-label={t('Auto')}
className={styles.autoCheckbox}
checked={autoUpdateEnabled}
onChange={(event) => setAutoUpdateEnabled(event.target.checked)}
/>{' '}
{t('Auto')}
</label>
);
};
@@ -495,14 +488,15 @@ export const PostPageStats = () => {
const { t } = useTranslation();
const params = useParams();
const location = useLocation();
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
const commentCid = params?.commentCid as string | undefined;
const resolvedAddress = useResolvedCommunityAddress();
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
const communityAddress = resolvedAddress || accountComment?.communityAddress;
const comment = useComment({ commentCid });
const comment = useComment({ commentCid, autoUpdate: autoUpdateEnabled });
const postCid = comment?.postCid ?? commentCid;
const post = useComment({ commentCid: postCid });
const post = useComment({ commentCid: postCid, autoUpdate: autoUpdateEnabled });
const archived = isCommentArchived(post);
const { closed, pinned, replyCount } = post || {};
+3 -1
View File
@@ -7,6 +7,7 @@ import StyleSelector from '../style-selector/style-selector';
import { ReturnButton, CatalogButton, TopButton, UpdateButton, AutoButton, PostPageStats, RefreshButton } from '../board-buttons/board-buttons';
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
import useReplyModalStore from '../../stores/use-reply-modal-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import { usePostPageNumber } from '../../hooks/use-post-page-number';
import { useDirectoryByAddress } from '../../hooks/use-directories';
@@ -205,12 +206,13 @@ export const ThreadFooterMobile = ({ postCid, threadNumber, communityAddress, is
const location = useLocation();
const params = useParams();
const { openReplyModalEmpty } = useReplyModalStore();
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
const isInAllView = isAllView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
const isInModView = isModView(location.pathname);
const post = useComment({ commentCid: postCid });
const post = useComment({ commentCid: postCid, autoUpdate: autoUpdateEnabled });
const { replyCount } = post || {};
const linkCount = useCountLinksInReplies(post);
const directoryEntry = useDirectoryByAddress(communityAddress);
@@ -42,6 +42,7 @@ import useReplyModalStore from '../../stores/use-reply-modal-store';
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
import useChallengesStore from '../../stores/use-challenges-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import { usePublishCommentModeration } from '@bitsocialnet/bitsocial-react-hooks';
@@ -884,6 +885,8 @@ const PostDesktop = ({
const freshRepliesForRender = useFreshReplies(repliesForRender);
useRegisterFreshReplies(resolvedPost, freshRepliesForRender);
const setResetFunction = useFeedResetStore((s) => s.setResetFunction);
const repliesResetRequestId = useThreadLiveUpdatesStore((state) => state.repliesResetRequestId);
const lastHandledRepliesResetRequestIdRef = useRef(repliesResetRequestId);
useEffect(() => {
if ((isInPostPageView || isInPendingPostView) && reset) {
setResetFunction(() => {
@@ -891,6 +894,11 @@ const PostDesktop = ({
});
}
}, [isInPostPageView, isInPendingPostView, reset, setResetFunction]);
useEffect(() => {
if (!reset || repliesResetRequestId === 0 || repliesResetRequestId === lastHandledRepliesResetRequestIdRef.current) return;
lastHandledRepliesResetRequestIdRef.current = repliesResetRequestId;
void reset();
}, [repliesResetRequestId, reset]);
const visiblelinksCount = useCountLinksInReplies(resolvedPost, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT);
const totalLinksCount = useCountLinksInReplies(resolvedPost);
const replyCount = freshRepliesForRender.length;
@@ -37,6 +37,7 @@ import useReplyModalStore from '../../stores/use-reply-modal-store';
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
import useChallengesStore from '../../stores/use-challenges-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import useQuotedByMap from '../../hooks/use-quoted-by-map';
@@ -608,6 +609,8 @@ const PostMobile = ({
useRegisterFreshReplies(resolvedPost, freshRepliesForRender);
const reset = (repliesResult as { reset?: () => Promise<void> }).reset;
const setResetFunction = useFeedResetStore((s) => s.setResetFunction);
const repliesResetRequestId = useThreadLiveUpdatesStore((state) => state.repliesResetRequestId);
const lastHandledRepliesResetRequestIdRef = useRef(repliesResetRequestId);
useEffect(() => {
if ((isInPostView || isInPendingPostView) && reset) {
setResetFunction(() => {
@@ -615,6 +618,11 @@ const PostMobile = ({
});
}
}, [isInPostView, isInPendingPostView, reset, setResetFunction]);
useEffect(() => {
if (!reset || repliesResetRequestId === 0 || repliesResetRequestId === lastHandledRepliesResetRequestIdRef.current) return;
lastHandledRepliesResetRequestIdRef.current = repliesResetRequestId;
void reset();
}, [repliesResetRequestId, reset]);
const isInPostPageView = isPostPageView(location.pathname, params);
const { hidden, unhide } = useHide({ cid });
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from 'react';
import { createInstance } from 'i18next';
import { I18nextProvider, initReactI18next } from 'react-i18next';
import { AutoButton, UpdateButton } from '../components/board-buttons/board-buttons';
import useThreadLiveUpdatesStore from '../stores/use-thread-live-updates-store';
type ReplySnapshot = {
cid: string;
content: string;
};
type ThreadSnapshot = {
postLabel: string;
replies: ReplySnapshot[];
version: number;
};
const i18n = createInstance();
void i18n.use(initReactI18next).init({
fallbackLng: 'en',
initImmediate: false,
lng: 'en',
resources: {
en: {
translation: {
Auto: 'Auto',
update: 'update',
},
},
},
});
const buildSnapshot = (version: number): ThreadSnapshot => ({
postLabel: `OP version ${version}`,
replies: Array.from({ length: version }, (_, index) => ({
cid: `reply-${index + 1}`,
content: index === 0 ? `reply 1 edited v${version}` : `reply ${index + 1} added in v${index + 1}`,
})),
version,
});
const Harness = () => {
const enabled = useThreadLiveUpdatesStore((state) => state.enabled);
const isUpdating = useThreadLiveUpdatesStore((state) => state.isUpdating);
const updateRequestId = useThreadLiveUpdatesStore((state) => state.updateRequestId);
const repliesResetRequestId = useThreadLiveUpdatesStore((state) => state.repliesResetRequestId);
const startUpdate = useThreadLiveUpdatesStore((state) => state.startUpdate);
const finishUpdate = useThreadLiveUpdatesStore((state) => state.finishUpdate);
const resetState = useThreadLiveUpdatesStore((state) => state.resetState);
const initialSnapshot = buildSnapshot(1);
const [serverSnapshot, setServerSnapshot] = useState(initialSnapshot);
const [visiblePostLabel, setVisiblePostLabel] = useState(initialSnapshot.postLabel);
const [visibleReplies, setVisibleReplies] = useState(initialSnapshot.replies);
const lastProcessedUpdateRequestIdRef = useRef(0);
const lastHandledRepliesResetIdRef = useRef(0);
const pendingRepliesRef = useRef<ReplySnapshot[] | null>(null);
useEffect(() => {
resetState();
return () => {
resetState();
};
}, [resetState]);
useEffect(() => {
if (!enabled) return;
setVisiblePostLabel(serverSnapshot.postLabel);
setVisibleReplies(serverSnapshot.replies);
}, [enabled, serverSnapshot]);
useEffect(() => {
if (updateRequestId === 0 || updateRequestId === lastProcessedUpdateRequestIdRef.current) return;
lastProcessedUpdateRequestIdRef.current = updateRequestId;
const snapshotToApply = serverSnapshot;
startUpdate();
pendingRepliesRef.current = snapshotToApply.replies;
const timeoutId = window.setTimeout(() => {
setVisiblePostLabel(snapshotToApply.postLabel);
finishUpdate(updateRequestId, true);
}, 20);
return () => {
window.clearTimeout(timeoutId);
};
}, [finishUpdate, serverSnapshot, startUpdate, updateRequestId]);
useEffect(() => {
if (repliesResetRequestId === 0 || repliesResetRequestId === lastHandledRepliesResetIdRef.current || !pendingRepliesRef.current) return;
lastHandledRepliesResetIdRef.current = repliesResetRequestId;
setVisibleReplies(pendingRepliesRef.current);
pendingRepliesRef.current = null;
}, [repliesResetRequestId]);
return (
<main style={{ fontFamily: 'sans-serif', lineHeight: 1.5, margin: '40px auto', maxWidth: 720, padding: '0 20px' }}>
<h1>Thread Auto Update E2E</h1>
<p>Use the real thread buttons below, then simulate incoming server updates.</p>
<div style={{ alignItems: 'center', display: 'flex', gap: 16, marginBottom: 20 }}>
<UpdateButton />
<AutoButton />
<button className='button' data-testid='simulate-server-update' onClick={() => setServerSnapshot((current) => buildSnapshot(current.version + 1))}>
Simulate server update
</button>
</div>
<div data-testid='updating-state'>{isUpdating ? 'updating' : 'idle'}</div>
<div data-testid='server-version'>Server version {serverSnapshot.version}</div>
<div data-testid='visible-post-label'>{visiblePostLabel}</div>
<div data-testid='visible-replies-count'>{visibleReplies.length}</div>
<div data-testid='visible-first-reply'>{visibleReplies[0]?.content ?? 'no replies'}</div>
<ul data-testid='visible-replies-list'>
{visibleReplies.map((reply) => (
<li key={reply.cid}>{reply.content}</li>
))}
</ul>
</main>
);
};
const ThreadAutoUpdateHarness = () => (
<I18nextProvider i18n={i18n}>
<Harness />
</I18nextProvider>
);
export default ThreadAutoUpdateHarness;
+24 -9
View File
@@ -16,21 +16,36 @@ import { Analytics } from '@vercel/analytics/react';
const isVercelDeployment =
typeof window !== 'undefined' && (window.location.hostname === '5chan.app' || window.location.hostname === 'www.5chan.app') && !window.isElectron;
const e2eStartHash = import.meta.env.VITE_E2E_START_HASH?.trim();
const shouldRenderThreadAutoUpdateHarness =
import.meta.env.DEV && typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('e2e') === 'thread-auto-update';
if (typeof window !== 'undefined' && e2eStartHash && window.location.hash.length === 0) {
window.location.hash = e2eStartHash.startsWith('#') ? e2eStartHash : `#${e2eStartHash}`;
}
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<React.StrictMode>
<Router>
<AppUpdateRegistration />
<App />
{isVercelDeployment && <Analytics />}
</Router>
</React.StrictMode>,
);
const renderRoot = async () => {
let threadAutoUpdateHarness: React.ComponentType | null = null;
if (shouldRenderThreadAutoUpdateHarness) {
threadAutoUpdateHarness = (await import('./e2e/thread-auto-update-harness')).default;
}
root.render(
<React.StrictMode>
{threadAutoUpdateHarness ? (
React.createElement(threadAutoUpdateHarness)
) : (
<Router>
<AppUpdateRegistration />
<App />
{isVercelDeployment && <Analytics />}
</Router>
)}
</React.StrictMode>,
);
};
void renderRoot();
// add back button in android app
CapacitorApp.addListener('backButton', ({ canGoBack }) => {
@@ -8,6 +8,7 @@ import usePostNumberStore from '../use-post-number-store';
import useReplyModalStore from '../use-reply-modal-store';
import useSelectedTextStore from '../use-selected-text-store';
import useSortingStore from '../use-sorting-store';
import useThreadLiveUpdatesStore from '../use-thread-live-updates-store';
const resetReplyModalStore = () => {
useReplyModalStore.setState({
@@ -41,6 +42,7 @@ describe('interaction stores', () => {
usePostNumberStore.setState({ numberToCid: {}, cidToNumber: {} });
useSelectedTextStore.getState().resetSelectedText();
useSortingStore.getState().setSortType('active');
useThreadLiveUpdatesStore.getState().resetState();
resetReplyModalStore();
Object.defineProperty(window, 'innerWidth', {
@@ -86,6 +88,61 @@ describe('interaction stores', () => {
expect(useSortingStore.getState().sortType).toBe('replyCount');
});
it('tracks thread live update toggle state and queues manual refresh requests', () => {
const store = useThreadLiveUpdatesStore.getState();
expect(store.enabled).toBe(false);
expect(store.isUpdating).toBe(false);
expect(store.updateRequestId).toBe(0);
expect(store.repliesResetRequestId).toBe(0);
store.setEnabled(true);
expect(useThreadLiveUpdatesStore.getState().enabled).toBe(true);
store.toggleEnabled();
expect(useThreadLiveUpdatesStore.getState().enabled).toBe(false);
store.requestUpdate();
expect(useThreadLiveUpdatesStore.getState().updateRequestId).toBe(1);
store.startUpdate();
expect(useThreadLiveUpdatesStore.getState().isUpdating).toBe(true);
store.finishUpdate(1);
expect(useThreadLiveUpdatesStore.getState()).toMatchObject({
isUpdating: false,
repliesResetRequestId: 1,
});
store.requestUpdate();
expect(useThreadLiveUpdatesStore.getState().updateRequestId).toBe(2);
store.startUpdate();
store.finishUpdate(2, false);
expect(useThreadLiveUpdatesStore.getState()).toMatchObject({
isUpdating: false,
repliesResetRequestId: 1,
});
store.requestUpdate();
store.startUpdate();
store.requestUpdate();
store.startUpdate();
store.finishUpdate(3);
expect(useThreadLiveUpdatesStore.getState()).toMatchObject({
isUpdating: true,
repliesResetRequestId: 3,
updateRequestId: 4,
});
store.finishUpdate(4);
expect(useThreadLiveUpdatesStore.getState()).toMatchObject({
isUpdating: false,
repliesResetRequestId: 4,
updateRequestId: 4,
});
});
it('queues challenges, abandons the current one, and logs abandon failures', async () => {
const abandonMock = vi.fn().mockResolvedValue(undefined);
const failingAbandonMock = vi.fn().mockRejectedValue(new Error('stop failed'));
@@ -0,0 +1,40 @@
import { create } from 'zustand';
interface ThreadLiveUpdatesState {
enabled: boolean;
isUpdating: boolean;
updateRequestId: number;
repliesResetRequestId: number;
setEnabled: (enabled: boolean) => void;
toggleEnabled: () => void;
requestUpdate: () => void;
startUpdate: () => void;
finishUpdate: (requestId: number, shouldResetReplies?: boolean) => void;
resetState: () => void;
}
const defaultState = {
enabled: false,
isUpdating: false,
updateRequestId: 0,
repliesResetRequestId: 0,
};
const useThreadLiveUpdatesStore = create<ThreadLiveUpdatesState>((set) => ({
...defaultState,
setEnabled: (enabled) => set({ enabled }),
toggleEnabled: () => set((state) => ({ enabled: !state.enabled })),
requestUpdate: () =>
set((state) => ({
updateRequestId: state.updateRequestId + 1,
})),
startUpdate: () => set({ isUpdating: true }),
finishUpdate: (requestId, shouldResetReplies = true) =>
set((state) => ({
isUpdating: state.updateRequestId === requestId ? false : state.isUpdating,
repliesResetRequestId: shouldResetReplies ? Math.max(state.repliesResetRequestId, requestId) : state.repliesResetRequestId,
})),
resetState: () => set(defaultState),
}));
export default useThreadLiveUpdatesStore;
+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 && (