perf(explorer): stop re-parsing markdown on every viewer re-render (#1118) (#1195)

Profiling the viewer in headless Chromium (real DOM, production React)
separated remark parse time, React commit time and DOM node count across
large-prose, large-code-block, deep-nested-list and GFM-table fixtures.

Two findings, one of which is fixed here.

1. Every re-render re-parsed the whole document and remounted the whole
   subtree. remarkPlugins and the ~20-entry components map were inline
   literals, so each render allocated fresh arrow components; React saw a new
   element type per mapped tag and replaced the DOM rather than updating it. A
   DOM-identity probe confirmed the remount on every fixture. Because
   react-markdown runs the remark pipeline inside its own render, an unrelated
   state change -- clicking Copy, toggling Preview/Source -- re-paid the full
   parse. Measured 364ms for a 1000-row GFM table and 1121ms for 2000 rows.

   Hoisting both props to module scope and memoising the rendered element on
   rawContent drops re-render cost to ~0.1ms across every fixture and removes
   the remount (DOM identity now survives). Initial mount and node switching
   are unchanged, since those are genuine parses.

2. Initial parse of large GFM tables is quadratic and lives upstream in
   remark-gfm: the same table text parses in 12.5ms without the plugin and
   1156ms with it at 2000 rows. Not addressed here -- any mitigation is a
   product decision and is tracked on the issue.

Note that document size is the wrong threshold for this: 562KB of prose parses
in 85ms while a 27KB GFM table takes 102ms. Row count, not bytes, predicts cost.

Rendered output is unchanged; the components map is moved verbatim. All 66
Explorer graph-workspace tests pass.

Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
This commit is contained in:
pravit-amp
2026-08-25 19:44:47 +05:30
committed by GitHub
co-authored by Pravit Ampapathini Sameer Kadam
parent c7d608570c
commit 50468f9c90
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, type CSSProperties } from "react";
import ReactMarkdown from "react-markdown";
import { useState, useRef, useEffect, useMemo, type CSSProperties } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } from "lucide-react";
import { GRAPH_THEME } from "./graphTheme";
@@ -47,6 +47,20 @@ export function MarkdownContentViewer({
const rawContent = typeof content === "string" ? content : "";
const hasContent = rawContent.trim().length > 0;
// react-markdown runs the whole remark pipeline synchronously inside its own
// render, so without this memo every unrelated re-render of this component --
// clicking Copy, toggling Preview/Source -- re-parses the entire document.
// Measured at ~364ms per re-render for a 1000-row GFM table (issue #1118).
// Keyed on rawContent so a genuine node change still re-parses exactly once.
const renderedMarkdown = useMemo(
() => (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{rawContent}
</ReactMarkdown>
),
[rawContent],
);
const handleCopy = async () => {
if (!hasContent) return;
try {
@@ -112,10 +126,24 @@ export function MarkdownContentViewer({
<code style={sourceCodeStyle}>{rawContent}</code>
</pre>
) : (
<div style={previewStyle}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
<div style={previewStyle}>{renderedMarkdown}</div>
)}
</div>
</div>
);
}
/* ─── Markdown rendering config ───────────────────────────────────── */
// Both props are hoisted to module scope so they keep a stable identity across
// renders. As inline literals they allocated a fresh plugin array and ~20 fresh
// arrow components on every render, which made React treat every mapped tag as a
// new element type and remount the entire rendered subtree instead of updating
// it (issue #1118). The arrow bodies only read the style constants below at call
// time, so declaring the map before them is safe.
const REMARK_PLUGINS = [remarkGfm];
const MARKDOWN_COMPONENTS: Components = {
// C-1: react-markdown passes a HAST `node` prop (the raw AST
// Element) to every custom component override via passNode:true.
// In React 19 any unknown prop spreads onto a native element are
@@ -193,16 +221,7 @@ export function MarkdownContentViewer({
</code>
);
},
}}
>
{rawContent}
</ReactMarkdown>
</div>
)}
</div>
</div>
);
}
};
/* ─── Styles ──────────────────────────────────────────────────────── */