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 &&