From 7ecc7d760fc90116518e26903d353c387f4351e4 Mon Sep 17 00:00:00 2001 From: saschabuehrle Date: Mon, 23 Mar 2026 19:50:24 +0100 Subject: [PATCH 1/4] fix: unwrap single paragraph child in list items --- .../__tests__/list-item-paragraphs.test.tsx | 23 +++++++++++++++++++ packages/streamdown/lib/components.tsx | 14 ++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 packages/streamdown/__tests__/list-item-paragraphs.test.tsx diff --git a/packages/streamdown/__tests__/list-item-paragraphs.test.tsx b/packages/streamdown/__tests__/list-item-paragraphs.test.tsx new file mode 100644 index 00000000..4ba9b5cf --- /dev/null +++ b/packages/streamdown/__tests__/list-item-paragraphs.test.tsx @@ -0,0 +1,23 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Streamdown } from "../index"; + +describe("list item paragraph wrapping", () => { + it("does not keep paragraph wrappers for loose list items", () => { + const markdown = ` +## Test + +- hello + +- world +`; + const { container } = render({markdown}); + + const items = container.querySelectorAll('[data-streamdown="list-item"]'); + + expect(items).toHaveLength(2); + for (const item of items) { + expect(item.querySelector("p")).toBeNull(); + } + }); +}); diff --git a/packages/streamdown/lib/components.tsx b/packages/streamdown/lib/components.tsx index 2449ca3a..b27847f9 100644 --- a/packages/streamdown/lib/components.tsx +++ b/packages/streamdown/lib/components.tsx @@ -189,13 +189,25 @@ type LiProps = WithNode; const MemoLi = memo( ({ children, className, node, ...props }: LiProps) => { const cn = useCn(); + + const childArray = Array.isArray(children) + ? children.filter((child) => child !== "\n" && child !== "") + : [children]; + + const normalizedChildren = + childArray.length === 1 && + isValidElement(childArray[0]) && + childArray[0].type === "p" + ? childArray[0].props.children + : children; + return (
  • p]:inline", className)} data-streamdown="list-item" {...props} > - {children} + {normalizedChildren}
  • ); }, From bbcc4c629486727de41a4459e488644a349a4aaf Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Mon, 14 Sep 2026 14:10:37 +0200 Subject: [PATCH 2/4] Fix paragraph detection in memoized list items --- packages/streamdown/lib/components.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/streamdown/lib/components.tsx b/packages/streamdown/lib/components.tsx index b27847f9..66f38102 100644 --- a/packages/streamdown/lib/components.tsx +++ b/packages/streamdown/lib/components.tsx @@ -8,6 +8,7 @@ import { lazy, type MouseEvent, memo, + type ReactNode, Suspense, useCallback, useContext, @@ -197,8 +198,10 @@ const MemoLi = memo( const normalizedChildren = childArray.length === 1 && isValidElement(childArray[0]) && - childArray[0].type === "p" - ? childArray[0].props.children + (childArray[0].type === MemoParagraph || + (childArray[0].props as { node: { tagname: string } }).node?.tagname === + "p") + ? (childArray[0].props as { children: ReactNode }).children : children; return ( From 9445225d41e187f0b5eac1e92c8abb9e002d5b5b Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Mon, 14 Sep 2026 14:25:47 +0200 Subject: [PATCH 3/4] Update list-animation-retrigger.test.tsx --- .../__tests__/list-animation-retrigger.test.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/streamdown/__tests__/list-animation-retrigger.test.tsx b/packages/streamdown/__tests__/list-animation-retrigger.test.tsx index 946c900c..46a7455e 100644 --- a/packages/streamdown/__tests__/list-animation-retrigger.test.tsx +++ b/packages/streamdown/__tests__/list-animation-retrigger.test.tsx @@ -66,10 +66,11 @@ describe("list animation retrigger fix (#410)", () => { // There should be MORE spans after (new item appeared) expect(afterSpans.length).toBeGreaterThan(initialSpans.length); - // The list is loose once a second group appears, so the items are rebuilt - // around a

    . What must not happen is the already-visible text re-running - // its entry animation: those characters carry --sd-duration:0ms. - expect(container.querySelector("li > p")).toBeTruthy(); + // The list is loose once a second group appears, so rehype rebuilds items + // around a

    ; MemoLi then unwraps a single paragraph child. What must not + // happen is the already-visible text re-running its entry animation: those + // characters carry --sd-duration:0ms. + expect(container.querySelector("li > p")).toBeNull(); // One character at the transition boundary is not suppressed: // prevContentLength counts inter-element whitespace, and the tight -> loose From f9418036a2ba9651b73981d95a2910c8841d8b0f Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Mon, 14 Sep 2026 14:56:52 +0200 Subject: [PATCH 4/4] chore: add changeset for list item paragraph unwrap --- .changeset/unwrap-list-item-paragraphs.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/unwrap-list-item-paragraphs.md diff --git a/.changeset/unwrap-list-item-paragraphs.md b/.changeset/unwrap-list-item-paragraphs.md new file mode 100644 index 00000000..25b91a49 --- /dev/null +++ b/.changeset/unwrap-list-item-paragraphs.md @@ -0,0 +1,9 @@ +--- +"streamdown": patch +--- + +fix: unwrap a single paragraph child inside list items + +Loose list items from the markdown pipeline are often wrapped in a `

    `. `MemoLi` now detects that sole paragraph child (including the memoized paragraph component) and renders its contents directly, so list items stay visually tight. Also updates the list animation retrigger test to match the unwrapped markup. + +Fixes #475