From d64a037f20acf50e6f80a0c97ae3cb833103aab4 Mon Sep 17 00:00:00 2001 From: Mark Needham Date: Wed, 25 Mar 2026 15:07:30 +0000 Subject: [PATCH 1/3] fix(CodeBlock): handle ReactNode children with angle-bracket placeholders JSX parses style content as React elements, causing SyntaxHighlighter to receive objects instead of strings and render [object Object]. Accept ReactNode children and stringify them, reconstructing syntax for element nodes. Co-Authored-By: Claude Sonnet 4.6 --- src/components/CodeBlock/CodeBlock.tsx | 28 ++++++++++++++++----- src/components/CodeBlock/CodeBlock.types.ts | 4 +-- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/components/CodeBlock/CodeBlock.tsx b/src/components/CodeBlock/CodeBlock.tsx index d38edfa59..56699c27e 100644 --- a/src/components/CodeBlock/CodeBlock.tsx +++ b/src/components/CodeBlock/CodeBlock.tsx @@ -1,9 +1,7 @@ -import React, { HTMLAttributes, useState } from 'react'; +import React, { HTMLAttributes, ReactNode, useState } from 'react'; import { Light as SyntaxHighlighter, createElement } from 'react-syntax-highlighter'; - import { EmptyButton } from '@/components/EmptyButton'; import { IconButton } from '@/components/IconButton'; - import { styled } from 'styled-components'; import useColorStyle from './useColorStyle'; import { CodeBlockProps, CodeThemeType } from './CodeBlock.types'; @@ -21,6 +19,23 @@ import tsx from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript.js' import plaintext from 'react-syntax-highlighter/dist/cjs/languages/hljs/plaintext.js'; /* eslint-enable import/extensions */ +const nodeToString = (node: ReactNode): string => { + if (node === null || node === undefined || typeof node === 'boolean') return ''; + if (typeof node === 'string') return node; + if (typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(nodeToString).join(''); + if (React.isValidElement(node)) { + const element = node as React.ReactElement<{ children?: ReactNode }>; + const tagName = typeof element.type === 'string' ? element.type : ''; + const children = element.props.children; + if (children !== undefined && children !== null) { + return `<${tagName}>${nodeToString(children)}`; + } + return `<${tagName}>`; + } + return String(node); +}; + SyntaxHighlighter.registerLanguage('sql', sql.default || sql); SyntaxHighlighter.registerLanguage('bash', bash.default || bash); SyntaxHighlighter.registerLanguage('json', json.default || json); @@ -109,12 +124,13 @@ export const CodeBlock = ({ const [errorCopy, setErrorCopy] = useState(false); const [wrap, setWrap] = useState(wrapLines); const customStyle = useColorStyle(theme); + const codeString = nodeToString(children); const copyCodeToClipboard = async () => { try { - await navigator.clipboard.writeText(children); + await navigator.clipboard.writeText(codeString); if (typeof onCopy == 'function') { - onCopy(children); + onCopy(codeString); } setCopied(true); setTimeout(() => setCopied(false), 2000); @@ -197,7 +213,7 @@ export const CodeBlock = ({ wrapLines={wrap || wrapLines} wrapLongLines={wrap || wrapLines} > - {children} + {codeString} ); diff --git a/src/components/CodeBlock/CodeBlock.types.ts b/src/components/CodeBlock/CodeBlock.types.ts index 5319f710e..b3de0ed39 100644 --- a/src/components/CodeBlock/CodeBlock.types.ts +++ b/src/components/CodeBlock/CodeBlock.types.ts @@ -1,4 +1,4 @@ -import { HTMLAttributes } from 'react'; +import { HTMLAttributes, ReactNode } from 'react'; export type CodeThemeType = 'light' | 'dark'; @@ -7,7 +7,7 @@ export interface CodeBlockProps extends Omit< 'children' | 'onCopy' > { language?: string; - children: string; + children: ReactNode; theme?: CodeThemeType; showLineNumbers?: boolean; showWrapButton?: boolean; From ca8bbe5cd03118c1d5f06459f9ebe3e84fea9db3 Mon Sep 17 00:00:00 2001 From: Mark Needham Date: Wed, 25 Mar 2026 15:18:50 +0000 Subject: [PATCH 2/3] test(CodeBlock): add tests for ReactNode children and copy behaviour Co-Authored-By: Claude Sonnet 4.6 --- src/components/CodeBlock/CodeBlock.test.tsx | 60 +++++++++++++++++++++ src/components/CodeBlock/CodeBlock.tsx | 8 +-- 2 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 src/components/CodeBlock/CodeBlock.test.tsx diff --git a/src/components/CodeBlock/CodeBlock.test.tsx b/src/components/CodeBlock/CodeBlock.test.tsx new file mode 100644 index 000000000..7071adc2a --- /dev/null +++ b/src/components/CodeBlock/CodeBlock.test.tsx @@ -0,0 +1,60 @@ +import { renderCUI } from '@/utils/test-utils'; +import userEvent from '@testing-library/user-event'; +import { CodeBlock } from '@/components/CodeBlock'; + +describe('CodeBlock', () => { + const renderCodeBlock = (children: React.ReactNode, language = 'bash') => + renderCUI({children}); + + it('renders a plain string', () => { + const { container } = renderCodeBlock('echo hello'); + expect(container.textContent).toContain('echo hello'); + }); + + it('renders $VARIABLE syntax as literal text', () => { + const { container } = renderCodeBlock('export FOO="$BAR"'); + expect(container.textContent).toContain('$BAR'); + }); + + it('renders angle-bracket placeholders as literal text instead of [object Object]', () => { + const { container } = renderCodeBlock( + <> + {'export PROJECT_ID="'} + + {'"'} + + ); + expect(container.textContent).not.toContain('[object Object]'); + expect(container.textContent).toContain('project-id'); + }); + + it('copies the plain-text string to clipboard', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + const code = 'echo hello'; + const { getByRole } = renderCodeBlock(code); + + await userEvent.click(getByRole('button')); + expect(writeText).toHaveBeenCalledWith(code); + }); + + it('calls onCopy with the plain-text string', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + const onCopy = vi.fn(); + + const code = 'echo hello'; + const { getByRole } = renderCUI( + + {code} + + ); + + await userEvent.click(getByRole('button')); + expect(onCopy).toHaveBeenCalledWith(code); + }); +}); diff --git a/src/components/CodeBlock/CodeBlock.tsx b/src/components/CodeBlock/CodeBlock.tsx index 56699c27e..e353e425e 100644 --- a/src/components/CodeBlock/CodeBlock.tsx +++ b/src/components/CodeBlock/CodeBlock.tsx @@ -20,10 +20,10 @@ import plaintext from 'react-syntax-highlighter/dist/cjs/languages/hljs/plaintex /* eslint-enable import/extensions */ const nodeToString = (node: ReactNode): string => { - if (node === null || node === undefined || typeof node === 'boolean') return ''; - if (typeof node === 'string') return node; - if (typeof node === 'number') return String(node); - if (Array.isArray(node)) return node.map(nodeToString).join(''); + if (node === null || node === undefined || typeof node === 'boolean') { return ''; } + if (typeof node === 'string') { return node; } + if (typeof node === 'number') { return String(node); } + if (Array.isArray(node)) { return node.map(nodeToString).join(''); } if (React.isValidElement(node)) { const element = node as React.ReactElement<{ children?: ReactNode }>; const tagName = typeof element.type === 'string' ? element.type : ''; From bad19f3262abf67701cc813cb5b805109ddfc4e7 Mon Sep 17 00:00:00 2001 From: Mark Needham Date: Wed, 25 Mar 2026 15:24:42 +0000 Subject: [PATCH 3/3] test(CodeBlock): fix TypeScript error in angle-bracket placeholder test Co-Authored-By: Claude Sonnet 4.6 --- src/components/CodeBlock/CodeBlock.test.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/components/CodeBlock/CodeBlock.test.tsx b/src/components/CodeBlock/CodeBlock.test.tsx index 7071adc2a..117b9525f 100644 --- a/src/components/CodeBlock/CodeBlock.test.tsx +++ b/src/components/CodeBlock/CodeBlock.test.tsx @@ -16,16 +16,10 @@ describe('CodeBlock', () => { expect(container.textContent).toContain('$BAR'); }); - it('renders angle-bracket placeholders as literal text instead of [object Object]', () => { - const { container } = renderCodeBlock( - <> - {'export PROJECT_ID="'} - - {'"'} - - ); + it('renders angle-bracket placeholders as literal text', () => { + const { container } = renderCodeBlock('export PROJECT_ID=""'); + expect(container.textContent).toContain(''); expect(container.textContent).not.toContain('[object Object]'); - expect(container.textContent).toContain('project-id'); }); it('copies the plain-text string to clipboard', async () => {