fix(frontend): sync streaming list markers with content (#4525)

* fix(frontend): sync streaming list markers with content

* fix(frontend): preserve streaming ordered-list counters
This commit is contained in:
Huixin615 2026-07-28 19:45:53 +08:00 committed by GitHub
parent 88f9f81877
commit 1bb93f9517
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 164 additions and 4 deletions

View File

@ -16,8 +16,9 @@ import {
import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown";
import {
preprocessStreamdownMarkdown,
rehypeStreamingListItems,
streamdownPluginsWithoutRawHtml,
streamdownWordAnimation,
streamdownSmoothStreamingAnimation,
} from "@/core/streamdown";
import {
SafeMessageResponse,
@ -241,8 +242,13 @@ export function MarkdownContent({
const effectiveRehypePlugins = useMemo(() => {
const base = streamdownPluginsWithoutRawHtml.rehypePlugins ?? [];
const extra = rehypePlugins ?? [];
return [...base, ...extra] as ClipboardSafeStreamdownProps["rehypePlugins"];
}, [rehypePlugins]);
const streaming = isStreamingRender ? [rehypeStreamingListItems] : [];
return [
...base,
...extra,
...streaming,
] as ClipboardSafeStreamdownProps["rehypePlugins"];
}, [isStreamingRender, rehypePlugins]);
const components = useMemo(() => {
const baseComponents = {
a: createMarkdownLinkComponent(),
@ -267,7 +273,7 @@ export function MarkdownContent({
rehypePlugins={effectiveRehypePlugins}
components={toStreamdownComponents(components)}
parseIncompleteMarkdown={isLoading}
animated={streamdownWordAnimation}
animated={streamdownSmoothStreamingAnimation}
isAnimating={isLoading}
>
{normalizedContent}

View File

@ -1,10 +1,12 @@
import { code } from "@streamdown/code";
import { mermaid } from "@streamdown/mermaid";
import type { Root } from "hast";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import type { StreamdownProps } from "streamdown";
import { visit } from "unist-util-visit";
const katexOptions = {
output: "html",
@ -37,6 +39,52 @@ export const streamdownWordAnimation = {
sep: "word",
} as const satisfies Exclude<StreamdownProps["animated"], boolean | undefined>;
export const streamdownSmoothStreamingAnimation = {
...streamdownWordAnimation,
// Streamdown defaults to 40ms per new word. A large chunk can therefore
// delay later list-item text by seconds while its native marker is already
// visible. Smooth content reveal owns the pacing here, so start every new
// word's fade together with its surrounding marker.
stagger: 0,
} as const satisfies Exclude<StreamdownProps["animated"], boolean | undefined>;
/**
* Keeps native list markers in step with Streamdown's word reveal.
*
* A trailing `2.` or `-` is parsed as an empty list item while content is
* streaming. Hide that transient item, then mark it for a matching marker
* animation as soon as its first child arrives. Keep mid-list empty items in
* the box tree so ordered-list counters never renumber later items.
*/
export function rehypeStreamingListItems() {
return (tree: Root) => {
visit(tree, "element", (node, index, parent) => {
if (node.tagName !== "li") {
return;
}
if (node.children.length === 0) {
const isTrailingListItem =
index !== undefined &&
parent?.type === "element" &&
(parent.tagName === "ol" || parent.tagName === "ul") &&
!parent.children
.slice(index + 1)
.some(
(sibling) =>
sibling.type === "element" && sibling.tagName === "li",
);
if (isTrailingListItem) {
node.properties.hidden = true;
}
return;
}
node.properties["data-streaming-list-item"] = "true";
});
};
}
export const streamdownPluginsWithoutRawHtml = {
plugins: streamdownPlugins.plugins,
remarkPlugins: streamdownPlugins.remarkPlugins,

View File

@ -72,6 +72,16 @@
@custom-variant dark (&:is(.dark *));
@keyframes streaming-list-marker-fade-in {
from {
color: transparent;
}
}
[data-streaming-list-item]::marker {
animation: streaming-list-marker-fade-in 200ms ease both;
}
@theme {
--font-sans:
ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",

View File

@ -0,0 +1,38 @@
import { afterEach, describe, expect, it } from "@rstest/core";
import { cleanup, render, waitFor } from "@testing-library/react";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
afterEach(cleanup);
describe("MarkdownContent streaming list transitions (DOM)", () => {
it("reveals the same list item with marker animation when content arrives", async () => {
const { container, rerender } = render(
<MarkdownContent content={"1. First\n\n2."} isLoading={true} />,
);
const initialItems = container.querySelectorAll<HTMLLIElement>(
'[data-streamdown="list-item"]',
);
const firstItem = initialItems[0]!;
const pendingItem = initialItems[1]!;
expect(pendingItem.hidden).toBe(true);
expect(pendingItem.hasAttribute("data-streaming-list-item")).toBe(false);
rerender(
<MarkdownContent content={"1. First\n\n2. Second"} isLoading={true} />,
);
await waitFor(() => {
expect(pendingItem.textContent).toContain("Second");
});
const revealedItems = container.querySelectorAll<HTMLLIElement>(
'[data-streamdown="list-item"]',
);
expect(revealedItems[0]).toBe(firstItem);
expect(revealedItems[1]).toBe(pendingItem);
expect(pendingItem.hidden).toBe(false);
expect(pendingItem.getAttribute("data-streaming-list-item")).toBe("true");
});
});

View File

@ -98,6 +98,7 @@ describe("MarkdownContent streaming animation", () => {
expect(html).toContain("data-sd-animate");
expect(html).toContain("--sd-animation:sd-fadeIn");
expect(html).toContain("--sd-duration:200ms");
expect(html).not.toContain("--sd-delay");
expect(html).not.toContain("animate-fade-in");
});
@ -108,6 +109,53 @@ describe("MarkdownContent streaming animation", () => {
});
});
describe("MarkdownContent streaming lists", () => {
it("hides a trailing empty ordered-list item until its content arrives", () => {
const html = renderMarkdown(["1. First", "", "2."].join("\n"), true);
expect(html).toContain('data-streaming-list-item="true"');
expect(html).toContain('data-streamdown="list-item" hidden=""');
});
it("hides a trailing empty unordered-list item until its content arrives", () => {
const html = renderMarkdown("-", true);
expect(html).toContain('data-streamdown="unordered-list"');
expect(html).toContain('data-streamdown="list-item" hidden=""');
expect(html).not.toContain('data-streaming-list-item="true"');
});
it("keeps a mid-list empty item visible so ordered counters stay stable", () => {
const html = renderMarkdown(
["1. First", "2.", "3. Third"].join("\n"),
true,
);
const listItems = html.match(/<li[^>]*>/g);
expect(listItems).toHaveLength(3);
expect(listItems?.[1]).not.toContain("hidden");
expect(html).not.toContain('hidden=""');
});
it("marks nested list items without hiding existing content", () => {
const html = renderMarkdown(
["1. Parent", " - Nested child"].join("\n"),
true,
);
expect(html.match(/data-streaming-list-item="true"/g)).toHaveLength(2);
expect(html).not.toContain('hidden=""');
});
it("leaves completed list markup outside streaming treatment", () => {
const html = renderMarkdown(["1. First", "", "2."].join("\n"), false);
expect(html).toContain('data-streamdown="ordered-list"');
expect(html).not.toContain("data-streaming-list-item");
expect(html).not.toContain('hidden=""');
});
});
describe("MarkdownContent strikethrough", () => {
it("preserves single tildes in temperature ranges", () => {
const html = renderMarkdown("周六23~30℃周日22~30℃", false);

View File

@ -8,6 +8,7 @@ import {
reasoningPlugins,
streamdownPlugins,
streamdownRenderingPlugins,
streamdownSmoothStreamingAnimation,
streamdownWordAnimation,
} from "@/core/streamdown/plugins";
@ -25,6 +26,15 @@ test("streaming word animation uses Streamdown's stable incremental animation",
});
});
test("smooth message streaming does not add a cumulative word delay", () => {
expect(streamdownSmoothStreamingAnimation).toEqual({
animation: "fadeIn",
duration: 200,
sep: "word",
stagger: 0,
});
});
test("streamdownPlugins includes rehypeRaw", () => {
expect(streamdownPlugins.rehypePlugins).toContain(rehypeRaw);
});