From e0a42e7899a20143f9d25429cd7c034bd9c3d3e4 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 21 May 2026 17:39:35 +0700 Subject: [PATCH] fix(pending-post): preserve sparse retry routes --- .../__tests__/use-delete-failed-post.test.tsx | 25 ++++++++++++++++++ src/hooks/use-delete-failed-post.ts | 17 ++++++++++-- .../__tests__/pending-post.test.tsx | 24 +++++++++++++++++ src/views/pending-post/pending-post.tsx | 26 ++++++++++++++++++- 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/hooks/__tests__/use-delete-failed-post.test.tsx b/src/hooks/__tests__/use-delete-failed-post.test.tsx index b7f7aad2..900835f7 100644 --- a/src/hooks/__tests__/use-delete-failed-post.test.tsx +++ b/src/hooks/__tests__/use-delete-failed-post.test.tsx @@ -16,6 +16,7 @@ const testState = vi.hoisted(() => ({ lastPublishOptions: undefined as Record | undefined, navigateMock: vi.fn(), publishCommentMock: vi.fn(async () => undefined), + publishIndex: undefined as number | undefined, })); vi.mock('react-router-dom', () => ({ @@ -28,6 +29,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ testState.lastPublishOptions = options; return { abandonPublish: testState.abandonPublishMock, + index: testState.publishIndex, 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', () => { beforeEach(() => { vi.clearAllMocks(); testState.lastPublishOptions = undefined; + testState.publishIndex = undefined; vi.stubGlobal('alert', testState.alertMock); useChallengesStore.setState({ challenges: [] }); @@ -142,6 +153,20 @@ describe('useDeleteFailedPost', () => { 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 () => { renderHook(failedPost, '/mu'); diff --git a/src/hooks/use-delete-failed-post.ts b/src/hooks/use-delete-failed-post.ts index 80b73d86..901426ec 100644 --- a/src/hooks/use-delete-failed-post.ts +++ b/src/hooks/use-delete-failed-post.ts @@ -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 { ChallengeVerification, Comment, PublishCommentOptions, deleteComment, usePublishComment } from '@bitsocial/bitsocial-react-hooks'; import { alertChallengeVerificationFailed } from '../lib/utils/challenge-utils'; @@ -71,6 +71,7 @@ export const getFailedPostRetryPublishOptions = (post?: FailedPost): PublishComm const useDeleteFailedPost = (post?: FailedPost, deleteRedirectPath?: string) => { const [isDeletingFailedPost, setIsDeletingFailedPost] = useState(false); const [isRetryingFailedPost, setIsRetryingFailedPost] = useState(false); + const [isRetryRedirectPending, setIsRetryRedirectPending] = useState(false); const addChallenge = useChallengesStore((state) => state.addChallenge); const navigate = useNavigate(); const abandonPublishRef = useRef<(() => Promise) | undefined>(); @@ -93,15 +94,25 @@ const useDeleteFailedPost = (post?: FailedPost, deleteRedirectPath?: string) => }, onError: (error: Error) => { console.error('Failed to retry failed post:', error); + setIsRetryRedirectPending(false); alert(`Failed to retry post: ${error.message}`); }, } : undefined, [abandonCurrentPublish, addChallenge, retryPublishOptions], ); - const { abandonPublish, publishComment } = usePublishComment(publishOptionsWithCallbacks); + const { abandonPublish, index: retryPostIndex, publishComment } = usePublishComment(publishOptionsWithCallbacks); abandonPublishRef.current = abandonPublish; + useEffect(() => { + if (!isRetryRedirectPending || typeof retryPostIndex !== 'number') { + return; + } + + setIsRetryRedirectPending(false); + navigate(`/pending/${retryPostIndex}`, { replace: true }); + }, [isRetryRedirectPending, navigate, retryPostIndex]); + const onDeleteFailedPost = useCallback(() => { if (isDeletingFailedPost || isRetryingFailedPost || !canDeleteFailedPost) { return; @@ -140,12 +151,14 @@ const useDeleteFailedPost = (post?: FailedPost, deleteRedirectPath?: string) => } setIsRetryingFailedPost(true); + setIsRetryRedirectPending(true); try { await deleteComment(targetComment); await publishComment(); } catch (error) { console.error('Failed to retry failed post:', error); + setIsRetryRedirectPending(false); alert(`Failed to retry post: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsRetryingFailedPost(false); diff --git a/src/views/pending-post/__tests__/pending-post.test.tsx b/src/views/pending-post/__tests__/pending-post.test.tsx index 66d0213a..9751284d 100644 --- a/src/views/pending-post/__tests__/pending-post.test.tsx +++ b/src/views/pending-post/__tests__/pending-post.test.tsx @@ -10,6 +10,7 @@ const act = (React as { act?: (cb: () => void | Promise) => void | Promise type TestComment = { cid?: string; communityAddress?: string; + index?: number; }; const testState = vi.hoisted(() => ({ @@ -136,6 +137,29 @@ describe('PendingPost', () => { 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 () => { testState.accountCommentIndex = '1'; testState.accountComments = [{}, {}]; diff --git a/src/views/pending-post/pending-post.tsx b/src/views/pending-post/pending-post.tsx index 4b7fdf37..d433ccf6 100644 --- a/src/views/pending-post/pending-post.tsx +++ b/src/views/pending-post/pending-post.tsx @@ -7,6 +7,30 @@ import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; import { getBoardPath } from '../../lib/utils/route-utils'; 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 { accountComments } = useAccountComments(); const { accountCommentIndex } = useParams<{ accountCommentIndex?: string }>(); @@ -23,7 +47,7 @@ const PendingPost = () => { (hasNormalizedAccountCommentIndex && normalizedAccountCommentIndex >= 0 && Number.isInteger(normalizedAccountCommentIndex) && - (accountComments?.length === 0 || normalizedAccountCommentIndex < accountComments.length)); + hasPendingAccountCommentIndex(accountComments, normalizedAccountCommentIndex)); useEffect(() => { if (!isValidAccountCommentIndex) {