`mode="static"` parses the whole document twice on mount (since the caret host in 2.7)
I maintainer di solito rispondono entro 1 giorno
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 2/5
- Tempo stimato
- 1-3 ore
- Idoneità per principianti
- 72/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- react, typescript
- Ambito
- frontend, performance
Direzione di ricerca
Il doppio rendering deriva dallo stato hydrated in lib/use-caret-host.ts, che viene impostato in un effect dopo il mount e ri-renderizza Streamdown. La modalità static renderizza Markdown direttamente in index.tsx (il ramo mode === "static"), quindi il secondo render riesegue l'intero parsing. Inizia dal test di riproduzione nell'issue, che conta i render di un componente p personalizzato e dovrebbe passare quando il conteggio è 1. È completato quando quel test passa e la suite esistente continua a passare. La modifica con useSyncExternalStore suggerita nell'issue è un punto di partenza, ma verificala prima rispetto al markup di hydration.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
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
- Lingua principale
- TypeScript
- Stelle
- 5.7k
- Fork
- 307
- Merge medio
- 1g 23h
- PR unite (30g)
- 21
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di vercel/streamdown
-
remend: unclosed `$$` in a table cell is closed on a new line when an earlier line has mathForse già presa @orinnz l’ha presa 12 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
vercel/streamdown#652 ·
I maintainer di solito rispondono entro 1 giorno
-
CJK autolink boundary handling keeps `**` in the URL when a bold URL is followed by CJK punctuationForse già presa Una pull request collegata a questa issue è aperta o già unita. Apertabug
Difficoltà 3/5 Mezza giornata Idoneità per principianti 50/100
vercel/streamdown#672 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 35/100
vercel/streamdown#669 ·
I maintainer di solito rispondono entro 1 giorno
-
Mermaid shows an error for a partial chart while its fence is still streamingForse già presa @slavco86 l’ha presa 2 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 25/100
vercel/streamdown#667 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
`rehype-raw` (and parse5) is always bundled, even when raw HTML is not usedForse già presa @sleitor l’ha presa 6 giorni fa. Aperta
Difficoltà 5/5 Più di una settimana Idoneità per principianti 35/100
vercel/streamdown#660 ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di vercel/streamdown
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 74/100
xdjs/MusicNerdWeb#1532 ·
I maintainer di solito rispondono entro 1 giorno
-
bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
sebastian-software/ardo#352 ·
I maintainer di solito rispondono entro 1 giorno
-
@react95/core good first issue
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 88/100
I maintainer di solito rispondono entro 3 giorni
-
@shikijs/stream: `ShikiStreamTokenizer.clone()` shares its `tokensStable` array with the originalForse già presa @xyrolle l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
I maintainer di solito rispondono entro 6 giorni
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
I maintainer di solito rispondono entro 1 giorno