Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/unwrap-list-item-paragraphs.md
Original file line number Diff line number Diff line change
@@ -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 `<p>`. `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
Original file line number Diff line number Diff line change
Expand Up @@ -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 <p>. 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 <p>; 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
Expand Down
23 changes: 23 additions & 0 deletions packages/streamdown/__tests__/list-item-paragraphs.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Streamdown>{markdown}</Streamdown>);

const items = container.querySelectorAll('[data-streamdown="list-item"]');

expect(items).toHaveLength(2);
for (const item of items) {
expect(item.querySelector("p")).toBeNull();
}
});
});
17 changes: 16 additions & 1 deletion packages/streamdown/lib/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
lazy,
type MouseEvent,
memo,
type ReactNode,
Suspense,
useCallback,
useContext,
Expand Down Expand Up @@ -229,13 +230,27 @@ type LiProps = WithNode<JSX.IntrinsicElements["li"]>;
const MemoLi = memo<LiProps>(
({ 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 === MemoParagraph ||
(childArray[0].props as { node: { tagname: string } }).node?.tagname ===
"p")
? (childArray[0].props as { children: ReactNode }).children
: children;

return (
<li
className={cn("py-1 [&>p]:inline", className)}
data-streamdown="list-item"
{...props}
>
{children}
{normalizedChildren}
</li>
);
},
Expand Down
Loading