fix: Prevent duplicate code blocks during streaming (#11261)

* fix code blocks error

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Carlos Coelho <80289056+carlosrcoelho@users.noreply.github.com>
This commit is contained in:
Cristhian Zanforlin Lousa
2026-01-15 10:30:15 -03:00
committed by GitHub
parent 4a673cffa7
commit 3ca5447956
3 changed files with 337 additions and 36 deletions

View File

@ -36,15 +36,9 @@ describe("codeBlockUtils", () => {
expect(result).toBe(true);
});
it("should_return_true_when_content_has_newlines", () => {
const result = isCodeBlock(undefined, {}, "line1\nline2\nline3");
expect(result).toBe(true);
});
it("should_return_true_when_content_has_single_newline", () => {
const result = isCodeBlock(undefined, {}, "line1\nline2");
expect(result).toBe(true);
});
// Note: Content with newlines alone is NOT considered a code block.
// This is intentional to prevent duplicate code block rendering during streaming.
// react-markdown identifies code blocks through language class or data-language attribute.
it("should_return_true_when_multiple_conditions_are_met", () => {
const result = isCodeBlock(
@ -99,32 +93,119 @@ describe("codeBlockUtils", () => {
expect(result).toBe(true);
});
it("should_handle_content_with_carriage_return_only", () => {
// \r alone should not be treated as newline for code block
const result = isCodeBlock(undefined, {}, "line1\rline2");
expect(result).toBe(false);
});
it("should_handle_content_with_crlf", () => {
const result = isCodeBlock(undefined, {}, "line1\r\nline2");
expect(result).toBe(true);
});
it("should_handle_content_with_trailing_newline_only", () => {
const result = isCodeBlock(undefined, {}, "single line\n");
expect(result).toBe(true);
});
it("should_handle_content_with_leading_newline_only", () => {
const result = isCodeBlock(undefined, {}, "\nsingle line");
expect(result).toBe(true);
});
it("should_handle_null_props_gracefully", () => {
// TypeScript would prevent this, but testing runtime safety
const result = isCodeBlock(undefined, null as any, "code");
expect(result).toBe(false);
});
it("should_return_false_for_content_with_newlines_but_no_language_indicator", () => {
// Newlines alone should NOT make content a code block
// This prevents duplicate rendering during streaming
const result = isCodeBlock(undefined, {}, "line1\nline2\nline3");
expect(result).toBe(false);
});
it("should_return_true_for_content_with_newlines_AND_language_class", () => {
const result = isCodeBlock(
"language-python",
{},
"def hello():\n print('world')",
);
expect(result).toBe(true);
});
it("should_return_true_for_content_with_newlines_AND_data_language", () => {
const result = isCodeBlock(
undefined,
{ "data-language": "python" },
"def hello():\n print('world')",
);
expect(result).toBe(true);
});
});
describe("streaming scenarios", () => {
it("should_not_treat_partial_streaming_content_as_block", () => {
// During streaming, content may arrive with newlines but without
// proper language indicators. This should NOT be treated as a block.
const partialContent = "def hello():\n print('world')";
const result = isCodeBlock(undefined, {}, partialContent);
expect(result).toBe(false);
});
it("should_treat_properly_marked_streaming_content_as_block", () => {
// When react-markdown properly identifies code, it adds language class
const partialContent = "def hello():\n print('world')";
const result = isCodeBlock("language-python", {}, partialContent);
expect(result).toBe(true);
});
it("should_handle_code_block_without_language_via_data_language", () => {
// Code blocks without language (```) get data-language attribute
const content = "some code\nwith newlines";
const result = isCodeBlock(undefined, { "data-language": "" }, content);
expect(result).toBe(true);
});
it("should_prevent_duplicate_blocks_during_streaming", () => {
// This test documents the bug fix:
// During streaming, if content with newlines was treated as a code block,
// it would cause the same code to appear as multiple blocks.
// By only using language class and data-language, we prevent this.
// Scenario: code is being streamed and arrives in chunks with newlines
const streamingChunk1 =
"output_data = {\n 'total_iterations': iteration_count,";
const streamingChunk2 =
"\n 'termination_reason': termination_reason,\n 'results': results,";
// Without proper language marker, these should NOT be code blocks
expect(isCodeBlock(undefined, {}, streamingChunk1)).toBe(false);
expect(isCodeBlock(undefined, {}, streamingChunk2)).toBe(false);
// With proper language marker, they should be code blocks
expect(isCodeBlock("language-python", {}, streamingChunk1)).toBe(true);
expect(isCodeBlock("language-python", {}, streamingChunk2)).toBe(true);
});
it("should_handle_multiline_code_consistently_regardless_of_newline_count", () => {
// All these should behave the same - only language marker matters
const singleLine = "print('hello')";
const twoLines = "x = 1\ny = 2";
const manyLines = "x = 1\ny = 2\nz = 3\nprint(x + y + z)";
// Without language marker, all should be inline
expect(isCodeBlock(undefined, {}, singleLine)).toBe(false);
expect(isCodeBlock(undefined, {}, twoLines)).toBe(false);
expect(isCodeBlock(undefined, {}, manyLines)).toBe(false);
// With language marker, all should be blocks
expect(isCodeBlock("language-python", {}, singleLine)).toBe(true);
expect(isCodeBlock("language-python", {}, twoLines)).toBe(true);
expect(isCodeBlock("language-python", {}, manyLines)).toBe(true);
});
it("should_not_create_block_from_text_that_looks_like_code", () => {
// Text content that happens to look like code should not become a block
// unless properly marked by the markdown parser
const codelikeText = `
"Condition threshold reached"
if iteration_count >= condition_threshold
else "Max iterations reached"
`;
expect(isCodeBlock(undefined, {}, codelikeText)).toBe(false);
});
it("should_handle_incomplete_code_blocks_during_streaming", () => {
// When streaming, code blocks may be incomplete (missing closing ```)
// The parser may not assign language class yet
const incompleteBlock = "def hello():\n print('world')";
expect(isCodeBlock(undefined, {}, incompleteBlock)).toBe(false);
// Once properly parsed, it will have the language class
expect(isCodeBlock("language-python", {}, incompleteBlock)).toBe(true);
});
});
});

View File

@ -0,0 +1,214 @@
/**
* This test simulates the bug reported by Rodrigo:
* - During streaming, code blocks appeared duplicated/split into 2 blocks
* - When leaving and returning to playground, it rendered correctly
*
* Root cause: The old isCodeBlock() used `hasNewlines` as a criterion,
* which caused content with newlines to be treated as code blocks
* even without proper language markers.
*/
// Simulate the OLD buggy implementation
function isCodeBlockOLD(
className: string | undefined,
props: Record<string, unknown> | undefined,
content: string,
): boolean {
const languageMatch = /language-(\w+)/.exec(className ?? "");
const hasLanguageClass = !!languageMatch;
const hasDataLanguage = "data-language" in (props ?? {});
const hasNewlines = content.includes("\n"); // BUG: This caused the issue!
return hasLanguageClass || hasDataLanguage || hasNewlines;
}
// Import the NEW fixed implementation
import { isCodeBlock as isCodeBlockNEW } from "../codeBlockUtils";
describe("Streaming Bug Simulation - Rodrigo's Report", () => {
describe("Scenario: Code being streamed in chunks", () => {
/**
* During streaming, react-markdown processes content progressively.
* The markdown parser may create multiple <code> elements when
* the code block is incomplete or being updated.
*
* Example markdown being streamed:
* ```python
* output_data = {
* "total_iterations": iteration_count,
* "termination_reason": termination_reason,
* }
* ```
*
* During streaming, this might be split into fragments by the parser.
*/
const streamingFragment1 = `output_data = {
"total_iterations": iteration_count,`;
const streamingFragment2 = `
"termination_reason": termination_reason,
"results": results,
}`;
const completeCode = `output_data = {
"total_iterations": iteration_count,
"termination_reason": termination_reason,
"results": results,
}`;
describe("OLD buggy behavior (before fix)", () => {
it("should_incorrectly_treat_streaming_fragments_as_code_blocks", () => {
// Without language class or data-language, but WITH newlines
// OLD implementation would return TRUE (BUG!)
const fragment1IsBlock = isCodeBlockOLD(
undefined,
{},
streamingFragment1,
);
const fragment2IsBlock = isCodeBlockOLD(
undefined,
{},
streamingFragment2,
);
// BUG: Both fragments are treated as separate code blocks!
expect(fragment1IsBlock).toBe(true); // Wrong! Should be false
expect(fragment2IsBlock).toBe(true); // Wrong! Should be false
// This caused the visual bug: 2 separate code blocks rendered
console.log("OLD BEHAVIOR (BUGGY):");
console.log(
`Fragment 1 is block: ${fragment1IsBlock} (should be false)`,
);
console.log(
`Fragment 2 is block: ${fragment2IsBlock} (should be false)`,
);
});
it("should_show_why_duplicate_blocks_appeared", () => {
// Simulating what happens during streaming:
// 1. First chunk arrives with newlines -> treated as block
// 2. Second chunk arrives with newlines -> treated as ANOTHER block
// Result: 2 code blocks instead of 1!
const chunks = [
"def hello():\n print('world')",
"\n\nself.status = f'Completed'",
"\n\nreturn Data(data=output_data)",
];
const blocksCreated = chunks.filter((chunk) =>
isCodeBlockOLD(undefined, {}, chunk),
);
// BUG: All 3 chunks become separate blocks!
expect(blocksCreated.length).toBe(3);
console.log(
`OLD: ${blocksCreated.length} blocks created from ${chunks.length} chunks`,
);
});
});
describe("NEW fixed behavior (after fix)", () => {
it("should_not_treat_streaming_fragments_as_code_blocks_without_language_marker", () => {
// Without language class or data-language
// NEW implementation correctly returns FALSE
const fragment1IsBlock = isCodeBlockNEW(
undefined,
{},
streamingFragment1,
);
const fragment2IsBlock = isCodeBlockNEW(
undefined,
{},
streamingFragment2,
);
// FIXED: Neither fragment is treated as a code block
expect(fragment1IsBlock).toBe(false);
expect(fragment2IsBlock).toBe(false);
console.log("NEW BEHAVIOR (FIXED):");
console.log(`Fragment 1 is block: ${fragment1IsBlock} (correct!)`);
console.log(`Fragment 2 is block: ${fragment2IsBlock} (correct!)`);
});
it("should_only_create_one_block_when_properly_marked", () => {
// When react-markdown properly identifies the code block,
// it adds the language class. Only then should it be a block.
const chunks = [
"def hello():\n print('world')",
"\n\nself.status = f'Completed'",
"\n\nreturn Data(data=output_data)",
];
// Without language markers: no blocks
const blocksWithoutMarker = chunks.filter((chunk) =>
isCodeBlockNEW(undefined, {}, chunk),
);
expect(blocksWithoutMarker.length).toBe(0);
// With language marker: all become blocks (as expected when properly parsed)
const blocksWithMarker = chunks.filter((chunk) =>
isCodeBlockNEW("language-python", {}, chunk),
);
expect(blocksWithMarker.length).toBe(3);
console.log(
`NEW: ${blocksWithoutMarker.length} blocks without marker (correct: 0)`,
);
console.log(
`NEW: ${blocksWithMarker.length} blocks with marker (correct: all)`,
);
});
it("should_handle_complete_code_correctly_when_streaming_ends", () => {
// When streaming completes and markdown is re-parsed,
// the code block gets proper language class
// During streaming (no marker yet)
const duringStreaming = isCodeBlockNEW(undefined, {}, completeCode);
expect(duringStreaming).toBe(false);
// After streaming (parser adds language class)
const afterStreaming = isCodeBlockNEW(
"language-python",
{},
completeCode,
);
expect(afterStreaming).toBe(true);
console.log("Complete code handling:");
console.log(`During streaming (no marker): ${duringStreaming}`);
console.log(`After streaming (with marker): ${afterStreaming}`);
});
});
describe("Visual comparison of the bug", () => {
it("should_demonstrate_the_difference", () => {
const testContent = `"Condition threshold reached"
if iteration_count >= condition_threshold
else "Max iterations reached"`;
const oldResult = isCodeBlockOLD(undefined, {}, testContent);
const newResult = isCodeBlockNEW(undefined, {}, testContent);
console.log("\n=== BUG DEMONSTRATION ===");
console.log("Content with newlines but NO language marker:");
console.log(
`OLD isCodeBlock(): ${oldResult} -> Would render as code block (BUG)`,
);
console.log(
`NEW isCodeBlock(): ${newResult} -> Correctly NOT a code block (FIXED)`,
);
expect(oldResult).toBe(true); // Bug
expect(newResult).toBe(false); // Fixed
});
});
});
});

View File

@ -5,19 +5,25 @@
* A code element is considered a block if any of the following conditions are met:
* 1. It has a language class (e.g., "language-python")
* 2. It has the "data-language" attribute (from some markdown parsers)
* 3. The content contains newlines (multi-line code)
*
* Note: We intentionally do NOT use newlines as a criterion for detecting code blocks.
* During streaming, react-markdown may create multiple code elements for a single
* code block, and using newlines would cause each fragment to be rendered as a
* separate block, resulting in duplicated/broken code block rendering.
*
* @param className - CSS class name that may contain language identifier
* @param props - Element props that may contain data-language attribute
* @param _content - Unused. Kept for backward compatibility with existing call sites.
*/
export function isCodeBlock(
className: string | undefined,
props: Record<string, unknown> | undefined,
content: string,
_content?: string,
): boolean {
const languageMatch = /language-(\w+)/.exec(className ?? "");
const hasLanguageClass = !!languageMatch;
const hasLanguageClass = /language-\w+/.test(className ?? "");
const hasDataLanguage = "data-language" in (props ?? {});
const hasNewlines = content.includes("\n");
return hasLanguageClass || hasDataLanguage || hasNewlines;
return hasLanguageClass || hasDataLanguage;
}
/**