Skip to content

Commit 6e91867

Browse files
timneutkenshaydenbleaselclaude
authored
Fix nested <details> tag (#403)
* Fix nested <details> tag * Add changeset for nested details fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Hayden Bleasel <hello@haydenbleasel.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bacd11b commit 6e91867

File tree

3 files changed

+183
-8
lines changed

3 files changed

+183
-8
lines changed

.changeset/nested-details-fix.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+
fix nested same-tag HTML block parsing in parseMarkdownIntoBlocks
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { render } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import { Streamdown } from "../index";
4+
import { parseMarkdownIntoBlocks } from "../lib/parse-blocks";
5+
6+
describe("Nested details elements", () => {
7+
describe("parseMarkdownIntoBlocks", () => {
8+
it("should keep nested same-tag HTML blocks as a single balanced block", () => {
9+
const markdown = `Text before
10+
11+
<details>
12+
<summary>Outer</summary>
13+
14+
<details>
15+
<summary>Inner</summary>
16+
17+
Inner content
18+
19+
</details>
20+
21+
Outer content after inner closes
22+
23+
</details>
24+
25+
Text after`;
26+
27+
const blocks = parseMarkdownIntoBlocks(markdown);
28+
const detailsBlock = blocks.find((b) => b.includes("<details>"));
29+
30+
const openCount = (detailsBlock?.match(/<details>/g) ?? []).length;
31+
const closeCount = (detailsBlock?.match(/<\/details>/g) ?? []).length;
32+
expect(openCount).toBe(2);
33+
expect(closeCount).toBe(2);
34+
expect(detailsBlock).toContain("Outer content after inner closes");
35+
expect(detailsBlock).not.toContain("Text after");
36+
});
37+
38+
it("should handle triple-nested same-tag HTML blocks", () => {
39+
const markdown = `<details>
40+
<summary>L1</summary>
41+
42+
<details>
43+
<summary>L2</summary>
44+
45+
<details>
46+
<summary>L3</summary>
47+
48+
Deep content
49+
50+
</details>
51+
52+
</details>
53+
54+
</details>`;
55+
56+
const blocks = parseMarkdownIntoBlocks(markdown);
57+
const detailsBlock = blocks.find((b) => b.includes("<details>"));
58+
59+
const openCount = (detailsBlock?.match(/<details>/g) ?? []).length;
60+
const closeCount = (detailsBlock?.match(/<\/details>/g) ?? []).length;
61+
expect(openCount).toBe(3);
62+
expect(closeCount).toBe(3);
63+
});
64+
});
65+
66+
describe("rendered DOM structure", () => {
67+
it("should nest inner details inside outer details", () => {
68+
const markdown = `<details>
69+
<summary>Outer</summary>
70+
71+
<details>
72+
<summary>Inner</summary>
73+
74+
Inner content
75+
76+
</details>
77+
78+
Outer content
79+
80+
</details>`;
81+
82+
const { container } = render(
83+
<Streamdown>{markdown}</Streamdown>
84+
);
85+
86+
const outer = container.querySelector("details");
87+
expect(outer).toBeTruthy();
88+
expect(
89+
outer?.querySelector(":scope > summary")?.textContent
90+
).toBe("Outer");
91+
92+
const inner = outer?.querySelector("details");
93+
expect(inner).toBeTruthy();
94+
expect(
95+
inner?.querySelector(":scope > summary")?.textContent
96+
).toBe("Inner");
97+
98+
expect(outer?.textContent).toContain("Outer content");
99+
expect(outer?.textContent).toContain("Inner content");
100+
});
101+
102+
it("should keep sibling content inside outer details after inner closes", () => {
103+
const markdown = `<details>
104+
<summary>Outer</summary>
105+
106+
<details>
107+
<summary>Inner</summary>
108+
109+
Inner content
110+
111+
</details>
112+
113+
### Heading after inner
114+
115+
| A | B |
116+
|---|---|
117+
| 1 | 2 |
118+
119+
</details>`;
120+
121+
const { container } = render(
122+
<Streamdown>{markdown}</Streamdown>
123+
);
124+
125+
const outer = container.querySelector("details");
126+
expect(outer).toBeTruthy();
127+
128+
// Heading and table should be inside the outer details, not leaked out
129+
const heading = outer?.querySelector("h3");
130+
expect(heading?.textContent).toContain("Heading after inner");
131+
132+
const table = outer?.querySelector("table");
133+
expect(table).toBeTruthy();
134+
});
135+
136+
it("should produce only one top-level details for a fully nested structure", () => {
137+
const markdown = `Before
138+
139+
<details>
140+
<summary>Top</summary>
141+
142+
<details>
143+
<summary>Nested</summary>
144+
145+
Nested content
146+
147+
</details>
148+
149+
</details>
150+
151+
After`;
152+
153+
const { container } = render(
154+
<Streamdown>{markdown}</Streamdown>
155+
);
156+
157+
const allDetails = container.querySelectorAll("details");
158+
const topLevel = [...allDetails].filter(
159+
(d) => !d.parentElement?.closest("details")
160+
);
161+
expect(topLevel.length).toBe(1);
162+
expect(
163+
topLevel[0].querySelector(":scope > summary")?.textContent
164+
).toBe("Top");
165+
});
166+
});
167+
});

packages/streamdown/lib/parse-blocks.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,17 @@ export const parseMarkdownIntoBlocks = (markdown: string): string[] => {
120120
// We're inside an HTML block, merge with the previous block
121121
mergedBlocks[mergedBlocksLen - 1] += currentBlock;
122122

123-
// Check if this token closes an HTML tag
124-
// Note: Marked's Lexer splits HTML blocks at blank lines, so the closing tag
125-
// may end up in a non-html token (e.g. paragraph). We must check all token types.
126-
const closingTagMatch = currentBlock.match(closingTagPattern);
127-
if (closingTagMatch) {
128-
const closingTag = closingTagMatch[1];
129-
// Check if this closes the most recent opening tag
130-
if (htmlStack.at(-1) === closingTag) {
123+
// Track nested opening and closing tags of the same type
124+
// so that inner closing tags don't prematurely close the outer block
125+
const trackedTag = htmlStack.at(-1) as string;
126+
const newOpenTags = countNonSelfClosingOpenTags(currentBlock, trackedTag);
127+
const newCloseTags = countClosingTags(currentBlock, trackedTag);
128+
129+
for (let i = 0; i < newOpenTags; i += 1) {
130+
htmlStack.push(trackedTag);
131+
}
132+
for (let i = 0; i < newCloseTags; i += 1) {
133+
if (htmlStack.length > 0 && htmlStack.at(-1) === trackedTag) {
131134
htmlStack.pop();
132135
}
133136
}

0 commit comments

Comments
 (0)