fix(replies): refresh thread backlinks from live account replies (#1058)

* fix(replies): refresh thread backlinks from live account replies

* refactor(replies): extract useRegisterFreshReplies hook per CodeRabbit
This commit is contained in:
Tommaso Casaburi
2026-03-12 15:08:45 +08:00
committed by GitHub
parent b3ec6495c5
commit ea792fe8a8
7 changed files with 252 additions and 36 deletions
@@ -0,0 +1,93 @@
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 useFreshReplies from '../use-fresh-replies';
(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(() => ({
accountComments: [] as Array<Record<string, unknown>>,
replies: [] as Array<Record<string, unknown>>,
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useAccountComments: () => ({
accountComments: testState.accountComments,
}),
}));
let container: HTMLDivElement;
let latestValue: ReturnType<typeof useFreshReplies>;
let root: Root;
const HookHarness = () => {
latestValue = useFreshReplies(testState.replies as never);
return null;
};
const renderHook = () => {
act(() => {
root.render(createElement(HookHarness));
});
};
describe('useFreshReplies', () => {
beforeEach(() => {
testState.accountComments = [];
testState.replies = [];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('replaces stale indexed replies with the latest account comment objects', () => {
testState.replies = [
{
cid: 'reply-cid',
content: 'stale reply',
index: 3,
number: undefined,
subplebbitAddress: 'music.eth',
},
{
cid: 'network-reply-cid',
content: 'network reply',
subplebbitAddress: 'music.eth',
},
];
testState.accountComments = [
{
cid: 'reply-cid',
content: 'fresh reply',
index: 3,
number: 27,
subplebbitAddress: 'music.eth',
},
];
renderHook();
expect(latestValue[0]).toBe(testState.accountComments[0] as never);
expect(latestValue[0]?.number).toBe(27);
expect(latestValue[1]).toBe(testState.replies[1] as never);
testState.accountComments = [
{
...testState.accountComments[0],
number: 28,
},
];
renderHook();
expect(latestValue[0]?.number).toBe(28);
});
});
@@ -0,0 +1,75 @@
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 useQuotedByMap from '../use-quoted-by-map';
import usePostNumberStore from '../../stores/use-post-number-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>;
const testState = vi.hoisted(() => ({
replies: [] as Array<Record<string, unknown>>,
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({}));
let container: HTMLDivElement;
let latestValue: ReturnType<typeof useQuotedByMap>;
let root: Root;
const HookHarness = () => {
latestValue = useQuotedByMap(testState.replies as never, 'music.eth');
return null;
};
const renderHook = () => {
act(() => {
root.render(createElement(HookHarness));
});
};
describe('useQuotedByMap', () => {
beforeEach(() => {
testState.replies = [];
usePostNumberStore.setState({
cidToNumber: { 'op-cid': 1 },
numberToCid: { 'music.eth': { 1: 'op-cid' } },
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('refreshes mapped replies when a published account reply later gains its post number', () => {
testState.replies = [
{
cid: 'reply-cid',
content: 'replying to >>1',
state: 'succeeded',
subplebbitAddress: 'music.eth',
},
];
renderHook();
expect(latestValue.get('op-cid')?.[0]?.number).toBeUndefined();
testState.replies = [
{
...testState.replies[0],
number: 42,
},
];
renderHook();
expect(latestValue.get('op-cid')?.[0]?.number).toBe(42);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { useMemo } from 'react';
import { Comment, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks';
const useFreshReplies = (replies: Comment[] = []) => {
const { accountComments } = useAccountComments();
return useMemo(() => {
if (!replies.length || !accountComments?.length) {
return replies;
}
const accountCommentsByIndex = new Map<number, Comment>();
for (const accountComment of accountComments) {
if (typeof accountComment?.index === 'number') {
accountCommentsByIndex.set(accountComment.index, accountComment);
}
}
let hasFreshReplies = false;
const nextReplies = replies.map((reply) => {
if (typeof reply?.index !== 'number') {
return reply;
}
const freshReply = accountCommentsByIndex.get(reply.index);
if (!freshReply) {
return reply;
}
hasFreshReplies = true;
return freshReply;
});
return hasFreshReplies ? nextReplies : replies;
}, [accountComments, replies]);
};
export default useFreshReplies;
+1 -1
View File
@@ -9,7 +9,7 @@ interface ReplyQuoteTargets {
}
const getReplyFingerprint = (reply: Comment) =>
`${reply?.cid ?? ''}|${reply?.deleted ? '1' : '0'}|${reply?.removed ? '1' : '0'}|${reply?.edit?.timestamp ?? ''}|${reply?.state ?? ''}`;
`${reply?.cid ?? ''}|${reply?.number ?? ''}|${reply?.deleted ? '1' : '0'}|${reply?.removed ? '1' : '0'}|${reply?.edit?.timestamp ?? ''}|${reply?.state ?? ''}`;
const areQuotedByMapsEquivalent = (previousMap: Map<string, Comment[]>, nextMap: Map<string, Comment[]>) => {
if (previousMap.size !== nextMap.size) {
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react';
import type { Comment } from '@bitsocialnet/bitsocial-react-hooks';
import usePostNumberStore from '../stores/use-post-number-store';
/**
* Registers post and fresh replies with the post-number store so backlinks
* and post numbers stay in sync when replies gain their number from account comments.
* Uses a fingerprint (cidsKey) to avoid redundant registerComments calls.
*/
const useRegisterFreshReplies = (post: Comment | undefined, freshRepliesForRender: Comment[]) => {
const registerComments = usePostNumberStore((s) => s.registerComments);
const prevCidsRef = useRef<string>('');
useEffect(() => {
const all = post ? [post, ...freshRepliesForRender] : freshRepliesForRender;
if (!all.length) return;
const cidsKey = all
.map((comment) => {
const commentKey = comment?.cid ?? (typeof comment?.index === 'number' ? `index:${comment.index}` : `timestamp:${comment?.timestamp ?? ''}`);
return `${comment?.subplebbitAddress ?? ''}:${commentKey}:${typeof comment?.number === 'number' ? comment.number : ''}`;
})
.sort()
.join(',');
if (cidsKey === prevCidsRef.current) return;
prevCidsRef.current = cidsKey;
registerComments(all);
}, [post, freshRepliesForRender, registerComments]);
};
export default useRegisterFreshReplies;