Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

`mode="static"` parses the whole document twice on mount (since the caret host in 2.7)

Đang mở Phù hợp với người mới
#673 0 bình luận 1 reaction 0 người được giao Xem trên GitHub

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
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

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. 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.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của vercel/streamdown

Tất cả issue của vercel/streamdown

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.