Skip to content

Commit 900d726

Browse files
committed
Resolves #354
1 parent 482c586 commit 900d726

File tree

3 files changed

+158
-8
lines changed

3 files changed

+158
-8
lines changed

.changeset/sharp-turtles-shop.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"streamdown": patch
3+
---
4+
5+
Code blocks render inside <p> tags causing hydration errors
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { render, waitFor } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import { Streamdown } from "../index";
4+
5+
describe("Code block hydration error fix", () => {
6+
it("should not wrap code blocks in <p> tags to prevent hydration errors", async () => {
7+
const markdown = `Here is some text.
8+
9+
\`\`\`typescript
10+
const foo = "bar";
11+
\`\`\`
12+
13+
More text after.`;
14+
15+
const { container } = render(<Streamdown>{markdown}</Streamdown>);
16+
17+
// Wait for the code block to render (it's lazily loaded)
18+
await waitFor(
19+
() => {
20+
const codeBlockWrapper = container.querySelector(
21+
'[data-streamdown="code-block"]'
22+
);
23+
expect(codeBlockWrapper).toBeTruthy();
24+
},
25+
{ timeout: 5000 }
26+
);
27+
28+
// Find the code block wrapper div
29+
const codeBlockWrapper = container.querySelector(
30+
'[data-streamdown="code-block"]'
31+
);
32+
expect(codeBlockWrapper?.tagName).toBe("DIV");
33+
34+
// Ensure the code block wrapper is NOT inside a <p> tag
35+
const parentElement = codeBlockWrapper?.parentElement;
36+
expect(parentElement?.tagName).not.toBe("P");
37+
});
38+
39+
it("should render inline code correctly inside paragraphs", () => {
40+
const markdown = "This is a paragraph with `inline code` in it.";
41+
42+
const { container } = render(<Streamdown>{markdown}</Streamdown>);
43+
44+
// Find the inline code
45+
const inlineCode = container.querySelector(
46+
'[data-streamdown="inline-code"]'
47+
);
48+
expect(inlineCode).toBeTruthy();
49+
expect(inlineCode?.tagName).toBe("CODE");
50+
51+
// Inline code SHOULD be inside a <p> tag
52+
const paragraph = container.querySelector("p");
53+
expect(paragraph).toBeTruthy();
54+
expect(paragraph?.contains(inlineCode)).toBe(true);
55+
});
56+
57+
it("should handle multiple code blocks", async () => {
58+
const markdown = `First code block:
59+
60+
\`\`\`javascript
61+
const a = 1;
62+
\`\`\`
63+
64+
Second code block:
65+
66+
\`\`\`python
67+
x = 2
68+
\`\`\``;
69+
70+
const { container } = render(<Streamdown>{markdown}</Streamdown>);
71+
72+
// Wait for the code blocks to render (they're lazily loaded)
73+
await waitFor(
74+
() => {
75+
const codeBlockWrappers = container.querySelectorAll(
76+
'[data-streamdown="code-block"]'
77+
);
78+
expect(codeBlockWrappers.length).toBe(2);
79+
},
80+
{ timeout: 5000 }
81+
);
82+
83+
// Find all code block wrappers
84+
const codeBlockWrappers = container.querySelectorAll(
85+
'[data-streamdown="code-block"]'
86+
);
87+
88+
// Ensure no code block wrapper is inside a <p> tag
89+
for (const wrapper of codeBlockWrappers) {
90+
const parentElement = wrapper.parentElement;
91+
expect(parentElement?.tagName).not.toBe("P");
92+
}
93+
});
94+
95+
it("should handle code blocks mixed with images", async () => {
96+
const markdown = `Some text.
97+
98+
![Image](https://example.com/image.png)
99+
100+
\`\`\`typescript
101+
const x = 1;
102+
\`\`\`
103+
104+
More text.`;
105+
106+
const { container } = render(<Streamdown>{markdown}</Streamdown>);
107+
108+
// Wait for the code block to render (it's lazily loaded)
109+
await waitFor(
110+
() => {
111+
const codeBlockWrapper = container.querySelector(
112+
'[data-streamdown="code-block"]'
113+
);
114+
expect(codeBlockWrapper).toBeTruthy();
115+
},
116+
{ timeout: 5000 }
117+
);
118+
119+
// Neither image nor code block should be in <p> tags
120+
const imageWrapper = container.querySelector(
121+
'[data-streamdown="image-wrapper"]'
122+
);
123+
const codeBlockWrapper = container.querySelector(
124+
'[data-streamdown="code-block"]'
125+
);
126+
127+
expect(imageWrapper).toBeTruthy();
128+
expect(codeBlockWrapper).toBeTruthy();
129+
130+
expect(imageWrapper?.parentElement?.tagName).not.toBe("P");
131+
expect(codeBlockWrapper?.parentElement?.tagName).not.toBe("P");
132+
});
133+
});

packages/streamdown/lib/components.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -833,14 +833,26 @@ const MemoParagraph = memo<ParagraphProps>(
833833
(child) => child !== null && child !== undefined && child !== ""
834834
);
835835

836-
// Check if there's exactly one child and it's an img element
837-
if (
838-
validChildren.length === 1 &&
839-
isValidElement(validChildren[0]) &&
840-
(validChildren[0].props as { node?: MarkdownNode }).node?.tagName ===
841-
"img"
842-
) {
843-
return <>{children}</>;
836+
// Check if there's exactly one child and it's a block-level element
837+
// (image or block code) to avoid wrapping in <p> which causes hydration errors
838+
if (validChildren.length === 1 && isValidElement(validChildren[0])) {
839+
const node = (validChildren[0].props as { node?: MarkdownNode }).node;
840+
const tagName = node?.tagName;
841+
842+
// Image: renders as <div>, cannot be nested in <p>
843+
if (tagName === "img") {
844+
return <>{children}</>;
845+
}
846+
847+
// Block code: renders as <div>, cannot be nested in <p>
848+
// Check if it's block code (multi-line) vs inline code (single line)
849+
if (tagName === "code") {
850+
const isBlockCode =
851+
node?.position?.start.line !== node?.position?.end.line;
852+
if (isBlockCode) {
853+
return <>{children}</>;
854+
}
855+
}
844856
}
845857

846858
return (

0 commit comments

Comments
 (0)