From 338f415f2a85012646920303c01507b99d45b388 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Mon, 20 Apr 2026 16:15:12 +0700 Subject: [PATCH] fix(pending posts): show publish errors --- .../__tests__/comment-content.test.tsx | 31 +++++++++++++++++ .../comment-content/comment-content.tsx | 18 +++++++++- .../__tests__/error-display.test.tsx | 33 +++++++++++++++++++ .../error-display/error-display.tsx | 18 ++++++++-- src/views/post/post.tsx | 4 +++ 5 files changed, 100 insertions(+), 4 deletions(-) diff --git a/src/components/comment-content/__tests__/comment-content.test.tsx b/src/components/comment-content/__tests__/comment-content.test.tsx index 8217724b..dde686b7 100644 --- a/src/components/comment-content/__tests__/comment-content.test.tsx +++ b/src/components/comment-content/__tests__/comment-content.test.tsx @@ -22,6 +22,8 @@ type TestComment = { edit?: { timestamp: number; }; + error?: unknown; + errors?: unknown[]; number?: number; original?: { content?: string; @@ -126,6 +128,19 @@ vi.mock('../../loading-ellipsis', () => ({ default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string), })); +vi.mock('../../error-display/error-display', () => ({ + default: ({ error, inline, showImmediately }: { error?: Error; inline?: boolean; showImmediately?: boolean }) => + createElement( + 'button', + { + 'data-testid': 'error-display', + 'data-inline': String(Boolean(inline)), + 'data-show-immediately': String(Boolean(showImmediately)), + }, + error?.message || String(error), + ), +})); + vi.mock('../../reply-quote-preview', () => ({ default: ({ isOP, @@ -393,4 +408,20 @@ describe('CommentContent', () => { }); expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('Publishing'); }); + + it('renders failed unpublished comment errors through ErrorDisplay', async () => { + testState.stateString = 'Failed'; + await renderContent({ + content: 'still pending', + errors: [new Error('spam blocker server error')], + postCid: 'post-2', + state: 'failed', + }); + + const errorDisplay = container.querySelector('[data-testid="error-display"]'); + expect(errorDisplay?.textContent).toBe('spam blocker server error'); + expect(errorDisplay?.getAttribute('data-inline')).toBe('true'); + expect(errorDisplay?.getAttribute('data-show-immediately')).toBe('true'); + expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); + }); }); diff --git a/src/components/comment-content/comment-content.tsx b/src/components/comment-content/comment-content.tsx index 436bb746..d2ecf6ed 100644 --- a/src/components/comment-content/comment-content.tsx +++ b/src/components/comment-content/comment-content.tsx @@ -11,6 +11,7 @@ import { isPostPageView } from '../../lib/utils/view-utils'; import useIsMobile from '../../hooks/use-is-mobile'; import useStateString from '../../hooks/use-state-string'; import LoadingEllipsis from '../../components/loading-ellipsis'; +import ErrorDisplay from '../../components/error-display/error-display'; import ReplyQuotePreview from '../../components/reply-quote-preview'; import Markdown from '../../components/markdown'; import Tooltip from '../../components/tooltip'; @@ -63,6 +64,12 @@ const useScopedCidToNumber = (cids: string[]) => { return cidToNumber; }; +const getFailedCommentError = (comment: Comment | undefined): unknown => { + if (comment?.state !== 'failed') return undefined; + if (comment.error) return comment.error; + return Array.isArray(comment.errors) ? comment.errors.find(Boolean) : undefined; +}; + const CommentContent = ({ comment: post, prependContent }: { comment: Comment; prependContent?: ReactNode }) => { const { t } = useTranslation(); const params = useParams(); @@ -123,9 +130,18 @@ const CommentContent = ({ comment: post, prependContent }: { comment: Comment; p const stateString = useStateString(resolvedPost); const hasFailedState = state === 'failed'; + const failedError = getFailedCommentError(resolvedPost); const loadingString = ( -
{!hasFailedState ? : stateString || capitalize(t('failed'))}
+
+ {failedError ? ( + + ) : !hasFailedState ? ( + + ) : ( + stateString || capitalize(t('failed')) + )} +
); return ( diff --git a/src/components/error-display/__tests__/error-display.test.tsx b/src/components/error-display/__tests__/error-display.test.tsx index 7b0d9d25..4a215fd0 100644 --- a/src/components/error-display/__tests__/error-display.test.tsx +++ b/src/components/error-display/__tests__/error-display.test.tsx @@ -102,6 +102,39 @@ describe('ErrorDisplay', () => { expect(container.textContent).toContain('copy failed'); }); + it('copies native Error instances with message, stack, and extra fields', async () => { + testState.copyToClipboardMock.mockResolvedValue(undefined); + const error = Object.assign(new Error('native failure'), { + details: { status: 504 }, + }); + error.stack = 'Error: native failure\n at publish'; + + await renderDisplay(error); + act(() => { + vi.advanceTimersByTime(1000); + }); + + const button = container.querySelector('button'); + expect(button?.textContent).toContain('error: native failure'); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(testState.copyToClipboardMock).toHaveBeenCalledWith( + JSON.stringify( + { + name: 'Error', + message: 'native failure', + stack: 'Error: native failure\n at publish', + details: { status: 504 }, + }, + null, + 2, + ), + ); + }); + it('supports compact custom labels that copy plain string errors immediately', async () => { testState.copyToClipboardMock.mockResolvedValue(undefined); diff --git a/src/components/error-display/error-display.tsx b/src/components/error-display/error-display.tsx index 2bd4f317..b0172b31 100644 --- a/src/components/error-display/error-display.tsx +++ b/src/components/error-display/error-display.tsx @@ -17,8 +17,20 @@ const serializeErrorForClipboard = (error: unknown): string => { return error; } + const serializableError = + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + ...Object.fromEntries(Object.entries(error)), + ...('cause' in error && error.cause ? { cause: error.cause } : {}), + } + : error; + try { - return JSON.stringify(error, null, 2); + const serializedError = JSON.stringify(serializableError, null, 2); + return serializedError && serializedError !== '{}' ? serializedError : String(error); } catch { return String(error); } @@ -54,8 +66,8 @@ const ErrorDisplay = ({ error, displayMessage, inline = false, showImmediately = return null; } - const originalDisplayMessage = displayMessage || (error?.message ? `${t('error')}: ${error.message}` : typeof error === 'string' ? error : null); - const canCopyError = !!error && (!!displayMessage || !!error?.message); + const originalDisplayMessage = displayMessage || (error?.message ? `${t('error')}: ${error.message}` : typeof error === 'string' ? error : error ? t('error') : null); + const canCopyError = !!error && !!originalDisplayMessage; const handleMessageClick = async () => { if (!canCopyError || state.feedbackMessageKey) return; diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index 7a28edf6..5d0541ee 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -235,6 +235,10 @@ export const Post = memo( prev?.postNumber === next?.postNumber && prev?.replyCount === next?.replyCount && prev?.updatedAt === next?.updatedAt && + prev?.state === next?.state && + prev?.publishingState === next?.publishingState && + prev?.error === next?.error && + prev?.errors === next?.errors && prev?.approved === next?.approved && prev?.locked === next?.locked && prev?.pinned === next?.pinned &&