Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

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

オープン 初心者向け
#673 コメント 3 件 リアクション 2 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@akhil9tiet がすでに取り組んでいます。

2026年10月15日 から。

  • #677 @akhil9tiet による — オープン

評価

難易度
2/5
見積もり時間
1〜3時間
初心者へのやさしさ
72/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
react, typescript

調査の方向性

二重レンダリングは lib/use-caret-host.ts の hydrated 状態が原因です。この状態はマウント後の effect で設定され、Streamdown を再レンダリングします。static モードでは index.tsx で Markdown が直接レンダリングされる(mode === "static" の分岐)ため、この2回目のレンダリングで解析全体が再実行されます。まず issue にある再現テストから始めてください。このテストはカスタム p コンポーネントのレンダリング回数を数えるもので、回数が 1 になれば合格するはずです。そのテストが通り、既存のスイートも通れば完了です。issue が提案している useSyncExternalStore の変更は出発点にすぎないので、まずハイドレーションのマークアップと照らして確認してください。

索引モデルが issue の本文から書いたものです。

説明

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

主要言語
TypeScript
スター
5.7k
フォーク
307
平均マージ
1日 22時間
マージ済み PR(30日)
22

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

vercel/streamdown のほかの issue

vercel/streamdown の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。