`mode="static"` parses the whole document twice on mount (since the caret host in 2.7)
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 2/5
- Thời gian dự kiến
- 1-3 giờ
- Mức phù hợp với người mới
- 72/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- react, typescript
- Lĩnh vực
- frontend, performance
Hướng nghiên cứu
Việc render hai lần đến từ state hydrated trong lib/use-caret-host.ts, state này được đặt trong một effect sau khi mount và khiến Streamdown render lại. Chế độ static render Markdown trực tiếp trong index.tsx (nhánh mode === "static"), nên lần render thứ hai chạy lại toàn bộ quá trình parse. Hãy bắt đầu với test tái hiện trong issue, test này đếm số lần render của một component p tùy chỉnh và nên pass khi số lần là 1. Xong khi test đó pass và bộ test hiện có vẫn pass. Thay đổi dùng useSyncExternalStore mà issue đề xuất chỉ là điểm khởi đầu, nên hãy kiểm tra nó với markup hydration trước.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Bug Description
In 2.7.0, every <Streamdown mode="static"> renders twice on mount, and the
second render parses the whole document again. Mounting a finished chat got
about 60% slower for us after upgrading from 2.4.0.
useCaretHost runs in every mode. It sets hydrated in an effect after mount:
// lib/use-caret-host.ts
const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
That state update renders Streamdown again. Streaming mode survives this
because Block is memoized. Static mode renders <Markdown> directly
(index.tsx, the mode === "static" branch), so the second render runs the
full remark/rehype pipeline again. It also re-renders every custom component.
Impact on a real 36k-character chat (15 replies with KaTeX, lists and tables),
rendered in static mode in vitest + happy-dom:
| 2.4.0 | 2.7.0 | 2.7.0, no 2nd render | |
|---|---|---|---|
| Mount, p50 | 65 ms | 104 ms | 70 ms |
| remark-parse time in a CPU profile | 263 ms | 521 ms | — |
We measured the last column with the smaller variant described at the end, which also removes the second render.
The second render runs after paint, so first paint is not delayed. It still
doubles the main-thread work for every static message that mounts.
Suggested fix: read hydrated from useSyncExternalStore. A client-only mount
gets true at once and does not render again. Hydration still renders with
false first, so the server markup keeps matching:
import {
type RefObject,
- useEffect,
useLayoutEffect,
useRef,
- useState,
+ useSyncExternalStore,
} from "react";
const HIDDEN_ATTRIBUTE = "data-sd-caret-hidden";
+const subscribeToNothing = () => () => {};
+
@@
- const [hydrated, setHydrated] = useState(false);
- useEffect(() => setHydrated(true), []);
+ const hydrated = useSyncExternalStore(
+ subscribeToNothing,
+ () => true,
+ () => false
+ );
With this change, the repro passes and the existing suite passes (93 files, 1242
tests).
A smaller change also works: set hydrated only when shouldHideCaret is true.
But then a caret that starts visible and later hides renders once with the caret
classes off. That causes the full-document style recalculation that the hook
tries to avoid.
Steps to Reproduce
import { render } from "@testing-library/react";
import { expect, it } from "vitest";
import { Streamdown } from "../index";
it("renders a static document once on mount", () => {
let renders = 0;
const P = ({ children }: { children?: React.ReactNode }) => {
renders++;
return <p>{children}</p>;
};
render(
<Streamdown components={{ p: P }} mode="static">
{"First paragraph.\n\nSecond paragraph."}
</Streamdown>,
);
expect(renders).toBe(2);
});
Expected Behavior
It should only render once per paragraph, and not twice.
Actual Behavior
It renders the paragraphs twice.
Code Sample
import { render } from "@testing-library/react";
import { expect, it } from "vitest";
import { Streamdown } from "../index";
it("renders a static document once on mount", () => {
let renders = 0;
const P = ({ children }: { children?: React.ReactNode }) => {
renders++;
return <p>{children}</p>;
};
render(
<Streamdown components={{ p: P }} mode="static">
{"First paragraph.\n\nSecond paragraph."}
</Streamdown>,
);
expect(renders).toBe(2);
});
Streamdown Version
2.7.0
React Version
19.3.0
Node.js Version
No response
Browser(s)
No response
Operating System
None
Additional Context
No response
- Ngôn ngữ chính
- TypeScript
- Star
- 5.7k
- Fork
- 307
- Merge trung bình
- 1 ngày 23 giờ
- Pull request đã merge (30 ngày)
- 21
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của vercel/streamdown
-
remend: unclosed `$$` in a table cell is closed on a new line when an earlier line has mathCó thể đã có người làm @orinnz đã nhận 11 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 85/100
vercel/streamdown#652 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
CJK autolink boundary handling keeps `**` in the URL when a bold URL is followed by CJK punctuationCó thể đã có người làm Có pull request liên kết đang mở hoặc đã được merge. Đang mởbug
Độ khó 3/5 Nửa ngày Mức phù hợp với người mới 50/100
vercel/streamdown#672 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Animation: CJK paragraphs only animate their first batch, and queued words pop in when isAnimating turns falseCó thể đã có người làm Có pull request liên kết đang mở hoặc đã được merge. Đang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 35/100
vercel/streamdown#669 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Mermaid shows an error for a partial chart while its fence is still streamingCó thể đã có người làm @slavco86 đã nhận 1 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 25/100
vercel/streamdown#667 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
`rehype-raw` (and parse5) is always bundled, even when raw HTML is not usedCó thể đã có người làm @sleitor đã nhận 6 ngày trước. Đang mở
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 35/100
vercel/streamdown#660 ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của vercel/streamdown
Issue tương tự
-
Bump anybuf version to ^1.0.0Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
wormhole-foundation/wormhole#5062 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
internal os specific
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
frostney/GocciaScript#1688 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
integration:hermes
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
vectorize-io/hindsight#5592 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
apache/rocketmq-dashboard#6712 ·
Maintainer thường phản hồi trong vòng 3 ngày
-
docs(stream-protocol): document messageMetatadata on start/finish and the message-metadata chunkCó thể đã có người làm Có pull request liên kết đang mở hoặc đã được merge. Đang mởfactory-active task-identify-harness-labels-done task-identify-issue-type-done
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
vercel/ai#23375 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày