fix(post): resolve thread community from CID and redirect stale routes

Fetch the comment community from the CID payload for initial useComment hints, and redirect directory thread URLs to the authoritative community instead of 404.
This commit is contained in:
Tommaso Casaburi
2026-07-17 16:32:01 +07:00
parent e14efac63a
commit cb15128ee8
5 changed files with 341 additions and 22 deletions
@@ -0,0 +1,85 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { decodeCommentCidCommunityAddress, useCommentCidPayload } from '../use-comment-cid-payload';
(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>;
const testState = vi.hoisted(() => ({
account: undefined as { pkc?: { fetchCid: ReturnType<typeof vi.fn> } } | undefined,
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
}));
let container: HTMLDivElement;
let latestSnapshot: ReturnType<typeof useCommentCidPayload> | undefined;
let root: Root;
const HookHarness = ({ cid }: { cid: string }) => {
latestSnapshot = useCommentCidPayload(cid);
return null;
};
describe('useCommentCidPayload', () => {
beforeEach(() => {
latestSnapshot = undefined;
testState.account = undefined;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('decodes the community name from a fetched CID wrapper', () => {
expect(
decodeCommentCidCommunityAddress('comment-cid', {
content: JSON.stringify({
communityName: 'business-and-finance.bso',
communityPublicKey: 'community-key',
content: 'thread body',
}),
}),
).toBe('business-and-finance.bso');
});
it('falls back to the community public key for unnamed communities', () => {
const encoded = new TextEncoder().encode(JSON.stringify({ communityPublicKey: 'community-key', content: 'thread body' }));
expect(decodeCommentCidCommunityAddress('comment-cid', { content: encoded })).toBe('community-key');
});
it('accepts an already decoded comment payload', () => {
expect(decodeCommentCidCommunityAddress('comment-cid', { communityName: 'outdoors.bso', content: 'thread body' })).toBe('outdoors.bso');
});
it('rejects CID payloads without a community identifier', () => {
expect(() => decodeCommentCidCommunityAddress('comment-cid', { content: JSON.stringify({ content: 'thread body' }) })).toThrow(
"CID 'comment-cid' did not contain a community identifier",
);
});
it('fetches the immutable CID once and publishes its community address', async () => {
const fetchCid = vi.fn().mockResolvedValue({
content: JSON.stringify({ communityName: 'videogames-strategy.bso', content: 'thread body' }),
});
testState.account = { pkc: { fetchCid } };
await act(async () => {
root.render(createElement(React.Fragment, null, createElement(HookHarness, { cid: 'comment-cid' }), createElement(HookHarness, { cid: 'comment-cid' })));
await Promise.resolve();
await Promise.resolve();
});
expect(fetchCid).toHaveBeenCalledOnce();
expect(fetchCid).toHaveBeenCalledWith({ cid: 'comment-cid' });
expect(latestSnapshot).toEqual({ communityAddress: 'videogames-strategy.bso', state: 'succeeded' });
});
});
+131
View File
@@ -0,0 +1,131 @@
import { useCallback, useSyncExternalStore } from 'react';
import { useAccount } from '@bitsocial/bitsocial-react-hooks';
type PkcWithCidFetch = {
fetchCid: (options: { cid: string }) => Promise<unknown>;
};
type CommentCidPayloadSnapshot = {
communityAddress?: string;
error?: Error;
state: 'idle' | 'fetching' | 'succeeded' | 'failed';
};
type CommentCidPayloadEntry = {
listeners: Set<() => void>;
request?: Promise<void>;
snapshot: CommentCidPayloadSnapshot;
};
const IDLE_SNAPSHOT: CommentCidPayloadSnapshot = { state: 'idle' };
const entriesByClient = new WeakMap<object, Map<string, CommentCidPayloadEntry>>();
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value && typeof value === 'object');
const decodeText = (value: unknown): string | undefined => {
if (typeof value === 'string') return value;
if (ArrayBuffer.isView(value)) return new TextDecoder().decode(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
if (value instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(value));
return undefined;
};
const getCommunityAddress = (payload: Record<string, unknown>): string | undefined => {
for (const key of ['communityName', 'communityAddress', 'subplebbitAddress', 'communityPublicKey']) {
const value = payload[key];
if (typeof value === 'string' && value) return value;
}
return undefined;
};
export const decodeCommentCidCommunityAddress = (cid: string, fetchedCid: unknown): string => {
const fetchedRecord = isRecord(fetchedCid) ? fetchedCid : undefined;
const encodedPayload = fetchedRecord && getCommunityAddress(fetchedRecord) ? fetchedRecord : (fetchedRecord?.content ?? fetchedCid);
const payloadText = decodeText(encodedPayload);
const payload = payloadText ? JSON.parse(payloadText) : encodedPayload;
if (!isRecord(payload)) {
throw new Error(`CID '${cid}' did not contain a comment object`);
}
const communityAddress = getCommunityAddress(payload);
if (!communityAddress) {
throw new Error(`CID '${cid}' did not contain a community identifier`);
}
return communityAddress;
};
const getClientEntries = (pkc: PkcWithCidFetch): Map<string, CommentCidPayloadEntry> => {
let entries = entriesByClient.get(pkc);
if (!entries) {
entries = new Map();
entriesByClient.set(pkc, entries);
}
return entries;
};
const getEntry = (pkc: PkcWithCidFetch, cid: string): CommentCidPayloadEntry => {
const entries = getClientEntries(pkc);
let entry = entries.get(cid);
if (!entry) {
entry = {
listeners: new Set(),
snapshot: IDLE_SNAPSHOT,
};
entries.set(cid, entry);
}
return entry;
};
const notify = (entry: CommentCidPayloadEntry) => {
for (const listener of entry.listeners) listener();
};
const startFetch = (pkc: PkcWithCidFetch, cid: string, entry: CommentCidPayloadEntry) => {
if (entry.request || entry.snapshot.state !== 'idle') return;
entry.snapshot = { state: 'fetching' };
notify(entry);
entry.request = pkc
.fetchCid({ cid })
.then((fetchedCid) => {
entry.snapshot = {
communityAddress: decodeCommentCidCommunityAddress(cid, fetchedCid),
state: 'succeeded',
};
})
.catch((error: unknown) => {
entry.snapshot = {
error: error instanceof Error ? error : new Error(String(error)),
state: 'failed',
};
})
.finally(() => {
entry.request = undefined;
notify(entry);
if (entry.listeners.size === 0) getClientEntries(pkc).delete(cid);
});
};
const subscribe = (pkc: PkcWithCidFetch, cid: string, listener: () => void) => {
const entry = getEntry(pkc, cid);
entry.listeners.add(listener);
startFetch(pkc, cid, entry);
return () => {
entry.listeners.delete(listener);
if (entry.listeners.size === 0 && !entry.request) getClientEntries(pkc).delete(cid);
};
};
const isPkcWithCidFetch = (value: unknown): value is PkcWithCidFetch => isRecord(value) && typeof value.fetchCid === 'function';
export const useCommentCidPayload = (commentCid: string | undefined): CommentCidPayloadSnapshot => {
const account = useAccount();
const pkc = isPkcWithCidFetch(account?.pkc) ? account.pkc : undefined;
const subscribeToPayload = useCallback((listener: () => void) => (pkc && commentCid ? subscribe(pkc, commentCid, listener) : () => {}), [commentCid, pkc]);
const getSnapshot = useCallback(() => (pkc && commentCid ? getEntry(pkc, commentCid).snapshot : IDLE_SNAPSHOT), [commentCid, pkc]);
return useSyncExternalStore(subscribeToPayload, getSnapshot, () => IDLE_SNAPSHOT);
};
+18
View File
@@ -24,6 +24,24 @@ export const getCommentCommunityAddress = (comment?: unknown) => {
return undefined;
};
export const hasAuthoritativeCommentPayload = (comment?: unknown): boolean => {
if (!comment || typeof comment !== 'object') {
return false;
}
const record = comment as {
timestamp?: unknown;
content?: unknown;
title?: unknown;
link?: unknown;
thumbnailUrl?: unknown;
deleted?: unknown;
removed?: unknown;
};
return Boolean(record.timestamp !== undefined || record.content || record.title || record.link || record.thumbnailUrl || record.deleted || record.removed);
};
const withResolvedReplyPages = <T>(replies: T): T => {
const replyCollection = replies as CommentWithCommunityAddress['replies'];
if (!replyCollection?.pages) {
+69 -5
View File
@@ -45,6 +45,7 @@ type TestComment = {
const testState = vi.hoisted(() => ({
accountCommentsByCid: {} as Record<string, TestComment | undefined>,
cachedComments: {} as Record<string, TestComment>,
cidCommunityAddress: undefined as string | undefined,
communityFieldAddress: undefined as string | undefined,
commentsByCid: {} as Record<string, TestComment>,
directories: [{ address: 'music-posting.eth', name: 'music-posting.eth', publicKey: 'music-public-key', title: '/mu/ - Music' }] as Array<{
@@ -58,6 +59,8 @@ const testState = vi.hoisted(() => ({
navigateMock: vi.fn(),
repliesByCommentCid: {} as Record<string, TestComment[]>,
resolvedCommunityAddress: 'music-posting.eth' as string | undefined,
resolvedDirectoryBoardPath: undefined as string | undefined,
isDirectoryCandidate: false,
community: {
error: undefined as Error | undefined,
shortAddress: 'music-posting.eth',
@@ -147,8 +150,16 @@ vi.mock('../../../hooks/use-stable-community', () => ({
},
}));
vi.mock('../../../hooks/use-comment-cid-payload', () => ({
useCommentCidPayload: () => ({ communityAddress: testState.cidCommunityAddress, state: testState.cidCommunityAddress ? 'succeeded' : 'idle' }),
}));
vi.mock('../../../hooks/use-resolved-community-address', () => ({
useResolvedCommunityAddress: () => testState.resolvedCommunityAddress,
useResolvedDirectoryBoardPath: () => ({
boardPath: testState.resolvedDirectoryBoardPath,
isDirectoryCandidate: testState.isDirectoryCandidate,
}),
}));
vi.mock('../../../hooks/use-directories', async () => {
@@ -302,12 +313,15 @@ describe('Post', () => {
vi.clearAllMocks();
testState.accountCommentsByCid = {};
testState.cachedComments = {};
testState.cidCommunityAddress = undefined;
testState.communityFieldAddress = undefined;
testState.commentsByCid = {};
testState.directories = [{ address: 'music-posting.eth', name: 'music-posting.eth', publicKey: 'music-public-key', title: '/mu/ - Music' }];
testState.editedCommentsByCid = {};
testState.isMobile = false;
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.resolvedDirectoryBoardPath = undefined;
testState.isDirectoryCandidate = false;
testState.repliesByCommentCid = {};
testState.useCommentCalls = [];
testState.evictThreadRefreshCachesMock.mockReset();
@@ -808,19 +822,69 @@ describe('Post', () => {
expect(window.scrollTo).not.toHaveBeenCalledWith(0, 0);
});
it('redirects thread routes whose fetched comment belongs to a different board', async () => {
it('redirects stale directory thread routes to the loaded comment community address', async () => {
testState.resolvedCommunityAddress = 'bizraelis.bso';
testState.isDirectoryCandidate = true;
testState.commentsByCid = {
'comment-1': {
cid: 'comment-1',
postCid: 'comment-1',
communityAddress: 'other.eth',
title: 'Other board thread',
communityAddress: 'business-and-finance.bso',
timestamp: 1,
title: 'Business thread',
},
};
await renderPostPage('/mu/thread/comment-1');
await renderPostPage('/biz/thread/comment-1?focus=1#reply-2');
expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true });
expect(testState.navigateMock).toHaveBeenCalledWith('/business-and-finance.bso/thread/comment-1?focus=1#reply-2', { replace: true });
});
it('uses the CID community as the initial useComment hint without rendering the raw CID payload', async () => {
testState.cidCommunityAddress = 'business-and-finance.bso';
testState.resolvedCommunityAddress = 'bizraelis.bso';
await renderPostPage('/biz/thread/comment-1');
expect(testState.useCommentCalls).toContainEqual({
autoUpdate: false,
commentCid: 'comment-1',
community: expect.objectContaining({ name: 'business-and-finance.bso' }),
});
expect(testState.navigateMock).not.toHaveBeenCalled();
});
it('does not trust a loading comment shell as the CID community source of truth', async () => {
testState.resolvedCommunityAddress = 'bizraelis.bso';
testState.commentsByCid = {
'comment-shell': {
cid: 'comment-shell',
communityAddress: 'business-and-finance.bso',
replyCount: 0,
state: 'initializing',
},
};
await renderPostPage('/biz/thread/comment-shell');
expect(testState.navigateMock).not.toHaveBeenCalled();
});
it('uses the directory code when the loaded comment community is the current winner', async () => {
testState.resolvedCommunityAddress = 'wrong-board.bso';
testState.resolvedDirectoryBoardPath = 'biz';
testState.isDirectoryCandidate = true;
testState.commentsByCid = {
'comment-1': {
cid: 'comment-1',
communityAddress: 'business-and-finance.bso',
timestamp: 1,
},
};
await renderPostPage('/wrong-board.bso/thread/comment-1');
expect(testState.navigateMock).toHaveBeenCalledWith('/biz/thread/comment-1', { replace: true });
});
it('hydrates multiboard thread pages from a legacy-only comment address', async () => {
+38 -17
View File
@@ -14,12 +14,13 @@ import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bit
import { useCommunityField } from '../../hooks/use-stable-community';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { isAllView } from '../../lib/utils/view-utils';
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
import { useResolvedCommunityAddress, useResolvedDirectoryBoardPath } from '../../hooks/use-resolved-community-address';
import { useDirectories } from '../../hooks/use-directories';
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
import { useCommentCidPayload } from '../../hooks/use-comment-cid-payload';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-utils';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { areSameBoardAddress, getBoardPath, isDirectoryBoard } from '../../lib/utils/route-utils';
import { getCommentCommunityAddress, hasAuthoritativeCommentPayload } from '../../lib/utils/comment-utils';
import useIsMobile from '../../hooks/use-is-mobile';
import ErrorDisplay from '../../components/error-display/error-display';
import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer/footer';
@@ -347,16 +348,14 @@ export const Post = memo(
const PostPage = () => {
const { t } = useTranslation();
const params = useParams();
const { key: locationKey, pathname, state: locationState } = useLocation();
const { commentCid } = params;
const { hash, key: locationKey, pathname, search, state: locationState } = useLocation();
const { boardIdentifier, commentCid } = useParams();
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 resolvedCommunityIdentifier = useCommunityIdentifier(resolvedCommunityAddress);
const isInAllView = isAllView(pathname);
const routeState = useMemo(() => {
// locationKey/pathname are intentional deps: getEffectiveRouteUserState falls back to the
@@ -366,12 +365,20 @@ const PostPage = () => {
return getEffectiveRouteUserState(locationState);
}, [locationKey, pathname, locationState]);
const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled, community: resolvedCommunityIdentifier });
const { communityAddress: cidCommunityAddress } = useCommentCidPayload(commentCid);
const commentCommunityIdentifier = useCommunityIdentifier(cidCommunityAddress ?? resolvedCommunityAddress);
const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled, community: commentCommunityIdentifier });
const queuedComment = useMemo(() => getQueuedCommentFromRouteState(routeState, commentCid), [routeState, commentCid]);
const comment = useMemo(() => mergeLocalCommentAuthor(mergeCommentFallback(resolvedComment, queuedComment), queuedComment), [resolvedComment, queuedComment]);
const commentCommunityAddress = getCommentCommunityAddress(comment);
const communityAddress = resolvedCommunityAddress ?? commentCommunityAddress;
const communityIdentifier = useCommunityIdentifier(communityAddress);
const authoritativeCommentCommunityAddress = hasAuthoritativeCommentPayload(comment) ? commentCommunityAddress : undefined;
const communityAddress = authoritativeCommentCommunityAddress ?? resolvedCommunityAddress;
const communityIdentifier = useCommunityIdentifier(authoritativeCommentCommunityAddress ?? cidCommunityAddress ?? resolvedCommunityAddress);
const directories = useDirectories();
const { boardPath: resolvedCommentBoardPath, isDirectoryCandidate: isCommentDirectoryCandidate } = useResolvedDirectoryBoardPath(authoritativeCommentCommunityAddress);
const canonicalCommentBoardPath = authoritativeCommentCommunityAddress
? (resolvedCommentBoardPath ?? (isCommentDirectoryCandidate ? authoritativeCommentCommunityAddress : getBoardPath(authoritativeCommentCommunityAddress, directories)))
: undefined;
const consumedThreadTopScrollRef = useRef<string | null>(null);
const previousThreadCidRef = useRef<string>(undefined);
const lastProcessedUpdateRequestIdRef = useRef(0);
@@ -379,17 +386,32 @@ const PostPage = () => {
const navigate = useNavigate();
useEffect(() => {
if (commentCommunityAddress && resolvedCommunityAddress && !areSameBoardAddress(commentCommunityAddress, resolvedCommunityAddress)) {
navigate('/not-found', { replace: true });
if (
!boardIdentifier ||
!canonicalCommentBoardPath ||
!authoritativeCommentCommunityAddress ||
!resolvedCommunityAddress ||
areSameBoardAddress(authoritativeCommentCommunityAddress, resolvedCommunityAddress)
) {
return;
}
}, [commentCommunityAddress, resolvedCommunityAddress, navigate]);
const routePrefix = `/${boardIdentifier}`;
if (!pathname.startsWith(`${routePrefix}/`)) return;
const canonicalPathname = `/${canonicalCommentBoardPath}${pathname.slice(routePrefix.length)}`;
if (canonicalPathname === pathname) return;
navigate(`${canonicalPathname}${search}${hash}`, { replace: true });
}, [authoritativeCommentCommunityAddress, boardIdentifier, canonicalCommentBoardPath, hash, navigate, pathname, resolvedCommunityAddress, search]);
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
const { error: communityError, shortAddress, title } = community || {};
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, autoUpdate: autoUpdateEnabled, community: communityIdentifier });
const postComment = useCommentWithFeedCache({
commentCid: comment?.postCid,
autoUpdate: autoUpdateEnabled,
community: authoritativeCommentCommunityAddress ? communityIdentifier : undefined,
});
const post = useMemo(() => (comment?.parentCid ? mergeCommentFallback(postComment, comment) : comment), [comment, postComment]);
threadRefreshCommentsRef.current = [comment, post];
const requestedThreadTopCid = getRequestedThreadTopCid(routeState);
@@ -420,7 +442,6 @@ const PostPage = () => {
}, [commentCid, locationKey, post?.cid, requestedThreadTopCid]);
useEffect(() => {
const boardIdentifier = params.boardIdentifier;
const isDirectory = boardIdentifier ? isDirectoryBoard(boardIdentifier, directories) : false;
let boardTitle: string;
@@ -435,7 +456,7 @@ const PostPage = () => {
const postTitle = post?.title?.slice(0, 30) || post?.content?.slice(0, 30);
const postTitlePart = postTitle ? ` - ${postTitle.trim()}...` : '';
document.title = `${boardTitle}${postTitlePart} - 5chan`;
}, [title, shortAddress, communityAddress, post?.title, post?.content, isInAllView, t, params.boardIdentifier, directories]);
}, [title, shortAddress, communityAddress, post?.title, post?.content, isInAllView, t, boardIdentifier, directories]);
const shouldShowCommentError = comment?.error?.message && !comment?.cid;
const shouldShowPostError = post?.error && post?.replyCount > 0 && post?.replies?.length === 0;