Skip to content

Commit e0dd596

Browse files
committed
fix: safely render structured output objects to prevent React error #31
When LLM providers (especially Anthropic) return structured content blocks like { text, type } objects, the UI crashes with React error #31 because these objects are passed directly as React children instead of being converted to strings first. This adds a safeRenderValue() utility that: - Extracts the .text property from { text, type } content blocks - Joins text from arrays of content blocks (Anthropic format) - Falls back to JSON.stringify for other objects - Returns primitives unchanged Applied to all chat message rendering paths: - Public deployed chat (ClientChatMessage) - Floating workspace chat (ChatMessage) - Chat streaming hook (stopStreaming content concatenation) - Chat store (appendMessageContent) Fixes #2725 AI Disclosure: This commit was authored by Claude Opus 4.6 (Anthropic), an AI agent operated by Maxwell Calkin (@MaxwellCalkin).
1 parent 8c0a2e0 commit e0dd596

File tree

6 files changed

+177
-30
lines changed

6 files changed

+177
-30
lines changed

apps/sim/app/chat/components/message/message.tsx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { memo, useMemo, useState } from 'react'
44
import { Check, Copy, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
55
import { Tooltip } from '@/components/emcn'
6+
import { safeRenderValue } from '@/lib/core/utils/safe-render'
67
import {
78
ChatFileDownload,
89
ChatFileDownloadAll,
@@ -50,9 +51,14 @@ export const ClientChatMessage = memo(
5051
return typeof message.content === 'object' && message.content !== null
5152
}, [message.content])
5253

53-
// Since tool calls are now handled via SSE events and stored in message.toolCalls,
54-
// we can use the content directly without parsing
55-
const cleanTextContent = message.content
54+
// Safely convert content to a renderable string to prevent React error #31
55+
// when workflow nodes return structured objects (e.g. { text, type })
56+
const cleanTextContent = useMemo(() => {
57+
if (isJsonObject) {
58+
return JSON.stringify(message.content, null, 2)
59+
}
60+
return safeRenderValue(message.content)
61+
}, [message.content, isJsonObject])
5662

5763
const content =
5864
message.type === 'user' ? (
@@ -161,14 +167,14 @@ export const ClientChatMessage = memo(
161167
)}
162168

163169
{/* Only render message bubble if there's actual text content (not just file count message) */}
164-
{message.content && !String(message.content).startsWith('Sent') && (
170+
{cleanTextContent && !cleanTextContent.startsWith('Sent') && (
165171
<div className='flex justify-end'>
166172
<div className='max-w-[80%] rounded-3xl bg-[#F4F4F4] px-4 py-3 dark:bg-gray-600'>
167173
<div className='whitespace-pre-wrap break-words text-base text-gray-800 leading-relaxed dark:text-gray-100'>
168174
{isJsonObject ? (
169-
<pre>{JSON.stringify(message.content, null, 2)}</pre>
175+
<pre>{cleanTextContent}</pre>
170176
) : (
171-
<span>{message.content as string}</span>
177+
<span>{cleanTextContent}</span>
172178
)}
173179
</div>
174180
</div>
@@ -184,11 +190,9 @@ export const ClientChatMessage = memo(
184190
<div>
185191
<div className='break-words text-base'>
186192
{isJsonObject ? (
187-
<pre className='text-gray-800 dark:text-gray-100'>
188-
{JSON.stringify(cleanTextContent, null, 2)}
189-
</pre>
193+
<pre className='text-gray-800 dark:text-gray-100'>{cleanTextContent}</pre>
190194
) : (
191-
<EnhancedMarkdownRenderer content={cleanTextContent as string} />
195+
<EnhancedMarkdownRenderer content={cleanTextContent} />
192196
)}
193197
</div>
194198
</div>
@@ -208,11 +212,7 @@ export const ClientChatMessage = memo(
208212
<button
209213
className='text-muted-foreground transition-colors hover:bg-muted'
210214
onClick={() => {
211-
const contentToCopy =
212-
typeof cleanTextContent === 'string'
213-
? cleanTextContent
214-
: JSON.stringify(cleanTextContent, null, 2)
215-
navigator.clipboard.writeText(contentToCopy)
215+
navigator.clipboard.writeText(cleanTextContent)
216216
setIsCopied(true)
217217
setTimeout(() => setIsCopied(false), 2000)
218218
}}

apps/sim/app/chat/hooks/use-chat-streaming.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5+
import { safeRenderValue } from '@/lib/core/utils/safe-render'
56
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
67
import type { ChatFile, ChatMessage } from '@/app/chat/components/message/message'
78
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'
@@ -84,12 +85,12 @@ export function useChatStreaming() {
8485

8586
// Only modify if the last message is from the assistant (as expected)
8687
if (lastMessage && lastMessage.type === 'assistant') {
87-
// Append a note that the response was stopped
88+
// Safely convert content to string before concatenation to prevent
89+
// React error #31 when content is a structured object
90+
const existingContent = safeRenderValue(lastMessage.content)
8891
const updatedContent =
89-
lastMessage.content +
90-
(lastMessage.content
91-
? '\n\n_Response stopped by user._'
92-
: '_Response stopped by user._')
92+
existingContent +
93+
(existingContent ? '\n\n_Response stopped by user._' : '_Response stopped by user._')
9394

9495
return [
9596
...prev.slice(0, -1),

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useMemo } from 'react'
2+
import { safeRenderValue } from '@/lib/core/utils/safe-render'
23
import { StreamingIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
34

45
interface ChatAttachment {
@@ -94,10 +95,7 @@ const WordWrap = ({ text }: { text: string }) => {
9495
*/
9596
export function ChatMessage({ message }: ChatMessageProps) {
9697
const formattedContent = useMemo(() => {
97-
if (typeof message.content === 'object' && message.content !== null) {
98-
return JSON.stringify(message.content, null, 2)
99-
}
100-
return String(message.content || '')
98+
return safeRenderValue(message.content)
10199
}, [message.content])
102100

103101
const handleAttachmentClick = (attachment: ChatAttachment) => {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { safeRenderValue } from '@/lib/core/utils/safe-render'
6+
7+
describe('safeRenderValue', () => {
8+
it('returns empty string for null', () => {
9+
expect(safeRenderValue(null)).toBe('')
10+
})
11+
12+
it('returns empty string for undefined', () => {
13+
expect(safeRenderValue(undefined)).toBe('')
14+
})
15+
16+
it('returns string values unchanged', () => {
17+
expect(safeRenderValue('hello world')).toBe('hello world')
18+
})
19+
20+
it('returns empty string for empty string input', () => {
21+
expect(safeRenderValue('')).toBe('')
22+
})
23+
24+
it('converts numbers to string', () => {
25+
expect(safeRenderValue(42)).toBe('42')
26+
expect(safeRenderValue(0)).toBe('0')
27+
expect(safeRenderValue(-1.5)).toBe('-1.5')
28+
})
29+
30+
it('converts booleans to string', () => {
31+
expect(safeRenderValue(true)).toBe('true')
32+
expect(safeRenderValue(false)).toBe('false')
33+
})
34+
35+
it('extracts text from {text, type} content block objects', () => {
36+
expect(safeRenderValue({ text: 'Hello from AI', type: 'text' })).toBe('Hello from AI')
37+
})
38+
39+
it('extracts text from {text} objects without type', () => {
40+
expect(safeRenderValue({ text: 'Some text' })).toBe('Some text')
41+
})
42+
43+
it('joins text from arrays of content blocks', () => {
44+
const contentArray = [
45+
{ text: 'Hello ', type: 'text' },
46+
{ text: 'world', type: 'text' },
47+
]
48+
expect(safeRenderValue(contentArray)).toBe('Hello world')
49+
})
50+
51+
it('handles arrays with mixed content types', () => {
52+
const mixedArray = [
53+
{ text: 'Text part', type: 'text' },
54+
{ type: 'tool_use', id: '123', name: 'search' },
55+
]
56+
const result = safeRenderValue(mixedArray)
57+
expect(result).toContain('Text part')
58+
expect(result).toContain('tool_use')
59+
})
60+
61+
it('handles string arrays', () => {
62+
expect(safeRenderValue(['hello', 'world'])).toBe('helloworld')
63+
})
64+
65+
it('JSON-stringifies plain objects without text property', () => {
66+
const obj = { key: 'value', nested: { a: 1 } }
67+
expect(safeRenderValue(obj)).toBe(JSON.stringify(obj, null, 2))
68+
})
69+
70+
it('JSON-stringifies empty objects', () => {
71+
expect(safeRenderValue({})).toBe('{}')
72+
})
73+
74+
it('handles empty arrays', () => {
75+
expect(safeRenderValue([])).toBe('[]')
76+
})
77+
78+
it('does not extract text when text property is not a string', () => {
79+
const obj = { text: 42, type: 'number' }
80+
expect(safeRenderValue(obj)).toBe(JSON.stringify(obj, null, 2))
81+
})
82+
})
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Safely converts a value to a string suitable for rendering in JSX.
3+
*
4+
* Prevents React error #31 ("Objects are not valid as a React child") by
5+
* ensuring that structured objects (e.g. `{ text, type }` content blocks
6+
* returned by LLM providers) are converted to a displayable string instead
7+
* of being passed directly as React children.
8+
*
9+
* @param value - The value to convert. Can be a string, number, boolean,
10+
* null, undefined, array, or object.
11+
* @returns A string representation safe for rendering in JSX.
12+
*/
13+
export function safeRenderValue(value: unknown): string {
14+
if (value === null || value === undefined) {
15+
return ''
16+
}
17+
18+
if (typeof value === 'string') {
19+
return value
20+
}
21+
22+
if (typeof value === 'number' || typeof value === 'boolean') {
23+
return String(value)
24+
}
25+
26+
if (typeof value === 'object') {
27+
// Handle content block objects like { text, type } from LLM providers
28+
// by extracting the text property when available
29+
if (
30+
!Array.isArray(value) &&
31+
'text' in value &&
32+
typeof (value as Record<string, unknown>).text === 'string'
33+
) {
34+
return (value as Record<string, unknown>).text as string
35+
}
36+
37+
// Handle arrays of content blocks (e.g. Anthropic's content array)
38+
if (Array.isArray(value)) {
39+
const textParts = value
40+
.map((item) => {
41+
if (typeof item === 'string') return item
42+
if (
43+
item &&
44+
typeof item === 'object' &&
45+
'text' in item &&
46+
typeof item.text === 'string'
47+
) {
48+
return item.text
49+
}
50+
return JSON.stringify(item)
51+
})
52+
.filter(Boolean)
53+
54+
if (textParts.length > 0) {
55+
return textParts.join('')
56+
}
57+
}
58+
59+
try {
60+
return JSON.stringify(value, null, 2)
61+
} catch {
62+
return String(value)
63+
}
64+
}
65+
66+
return String(value)
67+
}

apps/sim/stores/chat/store.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { v4 as uuidv4 } from 'uuid'
33
import { create } from 'zustand'
44
import { devtools, persist } from 'zustand/middleware'
5+
import { safeRenderValue } from '@/lib/core/utils/safe-render'
56
import type { ChatMessage, ChatState } from './types'
67
import { MAX_CHAT_HEIGHT, MAX_CHAT_WIDTH, MIN_CHAT_HEIGHT, MIN_CHAT_WIDTH } from './utils'
78

@@ -213,12 +214,10 @@ export const useChatStore = create<ChatState>()(
213214

214215
const newMessages = state.messages.map((message) => {
215216
if (message.id === messageId) {
216-
const newContent =
217-
typeof message.content === 'string'
218-
? message.content + content
219-
: message.content
220-
? String(message.content) + content
221-
: content
217+
// Safely convert existing content to string before concatenation
218+
// to prevent React error #31 when content is a structured object
219+
const existingContent = safeRenderValue(message.content)
220+
const newContent = existingContent + content
222221
logger.debug('[ChatStore] Updated message content', {
223222
messageId,
224223
oldLength: typeof message.content === 'string' ? message.content.length : 0,

0 commit comments

Comments
 (0)