fix(pending-post): preserve sparse retry routes

This commit is contained in:
Tommaso Casaburi
2026-05-21 17:39:35 +07:00
parent 75d90c8264
commit e0a42e7899
4 changed files with 89 additions and 3 deletions
@@ -16,6 +16,7 @@ const testState = vi.hoisted(() => ({
lastPublishOptions: undefined as Record<string, any> | undefined, lastPublishOptions: undefined as Record<string, any> | undefined,
navigateMock: vi.fn(), navigateMock: vi.fn(),
publishCommentMock: vi.fn(async () => undefined), publishCommentMock: vi.fn(async () => undefined),
publishIndex: undefined as number | undefined,
})); }));
vi.mock('react-router-dom', () => ({ vi.mock('react-router-dom', () => ({
@@ -28,6 +29,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
testState.lastPublishOptions = options; testState.lastPublishOptions = options;
return { return {
abandonPublish: testState.abandonPublishMock, abandonPublish: testState.abandonPublishMock,
index: testState.publishIndex,
publishComment: testState.publishCommentMock, publishComment: testState.publishCommentMock,
}; };
}, },
@@ -79,10 +81,19 @@ const renderHook = (post = failedPost, deleteRedirectPath?: string) => {
}); });
}; };
const flushEffects = async (count = 4) => {
for (let i = 0; i < count; i += 1) {
await act(async () => {
await Promise.resolve();
});
}
};
describe('useDeleteFailedPost', () => { describe('useDeleteFailedPost', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
testState.lastPublishOptions = undefined; testState.lastPublishOptions = undefined;
testState.publishIndex = undefined;
vi.stubGlobal('alert', testState.alertMock); vi.stubGlobal('alert', testState.alertMock);
useChallengesStore.setState({ challenges: [] }); useChallengesStore.setState({ challenges: [] });
@@ -142,6 +153,20 @@ describe('useDeleteFailedPost', () => {
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1); expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
}); });
it('redirects retry publishes to the new pending row', async () => {
testState.publishCommentMock.mockImplementationOnce(async () => {
testState.publishIndex = 12;
});
await act(async () => {
await latestValue.onRetryFailedPost();
});
renderHook();
await flushEffects();
expect(testState.navigateMock).toHaveBeenCalledWith('/pending/12', { replace: true });
});
it('redirects after deleting a failed post when a redirect path is provided', async () => { it('redirects after deleting a failed post when a redirect path is provided', async () => {
renderHook(failedPost, '/mu'); renderHook(failedPost, '/mu');
+15 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ChallengeVerification, Comment, PublishCommentOptions, deleteComment, usePublishComment } from '@bitsocial/bitsocial-react-hooks'; import { ChallengeVerification, Comment, PublishCommentOptions, deleteComment, usePublishComment } from '@bitsocial/bitsocial-react-hooks';
import { alertChallengeVerificationFailed } from '../lib/utils/challenge-utils'; import { alertChallengeVerificationFailed } from '../lib/utils/challenge-utils';
@@ -71,6 +71,7 @@ export const getFailedPostRetryPublishOptions = (post?: FailedPost): PublishComm
const useDeleteFailedPost = (post?: FailedPost, deleteRedirectPath?: string) => { const useDeleteFailedPost = (post?: FailedPost, deleteRedirectPath?: string) => {
const [isDeletingFailedPost, setIsDeletingFailedPost] = useState(false); const [isDeletingFailedPost, setIsDeletingFailedPost] = useState(false);
const [isRetryingFailedPost, setIsRetryingFailedPost] = useState(false); const [isRetryingFailedPost, setIsRetryingFailedPost] = useState(false);
const [isRetryRedirectPending, setIsRetryRedirectPending] = useState(false);
const addChallenge = useChallengesStore((state) => state.addChallenge); const addChallenge = useChallengesStore((state) => state.addChallenge);
const navigate = useNavigate(); const navigate = useNavigate();
const abandonPublishRef = useRef<(() => Promise<void>) | undefined>(); const abandonPublishRef = useRef<(() => Promise<void>) | undefined>();
@@ -93,15 +94,25 @@ const useDeleteFailedPost = (post?: FailedPost, deleteRedirectPath?: string) =>
}, },
onError: (error: Error) => { onError: (error: Error) => {
console.error('Failed to retry failed post:', error); console.error('Failed to retry failed post:', error);
setIsRetryRedirectPending(false);
alert(`Failed to retry post: ${error.message}`); alert(`Failed to retry post: ${error.message}`);
}, },
} }
: undefined, : undefined,
[abandonCurrentPublish, addChallenge, retryPublishOptions], [abandonCurrentPublish, addChallenge, retryPublishOptions],
); );
const { abandonPublish, publishComment } = usePublishComment(publishOptionsWithCallbacks); const { abandonPublish, index: retryPostIndex, publishComment } = usePublishComment(publishOptionsWithCallbacks);
abandonPublishRef.current = abandonPublish; abandonPublishRef.current = abandonPublish;
useEffect(() => {
if (!isRetryRedirectPending || typeof retryPostIndex !== 'number') {
return;
}
setIsRetryRedirectPending(false);
navigate(`/pending/${retryPostIndex}`, { replace: true });
}, [isRetryRedirectPending, navigate, retryPostIndex]);
const onDeleteFailedPost = useCallback(() => { const onDeleteFailedPost = useCallback(() => {
if (isDeletingFailedPost || isRetryingFailedPost || !canDeleteFailedPost) { if (isDeletingFailedPost || isRetryingFailedPost || !canDeleteFailedPost) {
return; return;
@@ -140,12 +151,14 @@ const useDeleteFailedPost = (post?: FailedPost, deleteRedirectPath?: string) =>
} }
setIsRetryingFailedPost(true); setIsRetryingFailedPost(true);
setIsRetryRedirectPending(true);
try { try {
await deleteComment(targetComment); await deleteComment(targetComment);
await publishComment(); await publishComment();
} catch (error) { } catch (error) {
console.error('Failed to retry failed post:', error); console.error('Failed to retry failed post:', error);
setIsRetryRedirectPending(false);
alert(`Failed to retry post: ${error instanceof Error ? error.message : 'Unknown error'}`); alert(`Failed to retry post: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally { } finally {
setIsRetryingFailedPost(false); setIsRetryingFailedPost(false);
@@ -10,6 +10,7 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise
type TestComment = { type TestComment = {
cid?: string; cid?: string;
communityAddress?: string; communityAddress?: string;
index?: number;
}; };
const testState = vi.hoisted(() => ({ const testState = vi.hoisted(() => ({
@@ -136,6 +137,29 @@ describe('PendingPost', () => {
expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true }); expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true });
}); });
it('keeps sparse pending account comment indices addressable', async () => {
testState.accountCommentIndex = '1';
testState.accountComments = [{ index: 1 }];
testState.post = {
communityAddress: 'music-posting.eth',
index: 1,
};
await renderPendingPost();
expect(container.querySelector('[data-testid="post-view"]')?.textContent).toBe('no-post');
expect(testState.navigateMock).not.toHaveBeenCalledWith('/not-found', { replace: true });
});
it('redirects missing sparse pending account comment indices to not found', async () => {
testState.accountCommentIndex = '0';
testState.accountComments = [{ index: 1 }];
await renderPendingPost();
expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true });
});
it('redirects resolved pending posts to the canonical thread route', async () => { it('redirects resolved pending posts to the canonical thread route', async () => {
testState.accountCommentIndex = '1'; testState.accountCommentIndex = '1';
testState.accountComments = [{}, {}]; testState.accountComments = [{}, {}];
+25 -1
View File
@@ -7,6 +7,30 @@ import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getBoardPath } from '../../lib/utils/route-utils'; import { getBoardPath } from '../../lib/utils/route-utils';
import { Post } from '../post'; import { Post } from '../post';
type PendingAccountComment = {
index?: number;
};
const hasPendingAccountCommentIndex = (accountComments: PendingAccountComment[] | undefined, accountCommentIndex: number) => {
if (!accountComments || accountComments.length === 0) {
return true;
}
let hasExplicitIndices = false;
for (const accountComment of accountComments) {
if (typeof accountComment?.index !== 'number') {
continue;
}
hasExplicitIndices = true;
if (accountComment.index === accountCommentIndex) {
return true;
}
}
return hasExplicitIndices ? false : accountCommentIndex < accountComments.length;
};
const PendingPost = () => { const PendingPost = () => {
const { accountComments } = useAccountComments(); const { accountComments } = useAccountComments();
const { accountCommentIndex } = useParams<{ accountCommentIndex?: string }>(); const { accountCommentIndex } = useParams<{ accountCommentIndex?: string }>();
@@ -23,7 +47,7 @@ const PendingPost = () => {
(hasNormalizedAccountCommentIndex && (hasNormalizedAccountCommentIndex &&
normalizedAccountCommentIndex >= 0 && normalizedAccountCommentIndex >= 0 &&
Number.isInteger(normalizedAccountCommentIndex) && Number.isInteger(normalizedAccountCommentIndex) &&
(accountComments?.length === 0 || normalizedAccountCommentIndex < accountComments.length)); hasPendingAccountCommentIndex(accountComments, normalizedAccountCommentIndex));
useEffect(() => { useEffect(() => {
if (!isValidAccountCommentIndex) { if (!isValidAccountCommentIndex) {