perf(web): chat Markdown parses about twice as fast with the same output#27685

merged1
t3/optimize-markdown-rendering
t3/optimize-markdown-rendering-2
opened13h agobyJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 6h ago

Stacked on #27480. Even after #27480's cache, a reply that isn't cached yet still pays the full micromark + remark + rehype pipeline. On large threads that is about 80 ms of parsing when a thread opens. Most of it was GFM checking for syntax the text could never contain.

This keeps the same engine and plugins, and the rendered output stays the same:

  • Skipping GFM syntax that cannot match. chat/markdownSyntaxGates.ts replaces remark-parse's parser. GFM constructs whose trigger characters are missing are left out, so email links aren't tried at every word without an @ and tables aren't tried at every line when no delimiter row exists. Link and table checks also run per position, from micromark's own previous/tokenize hooks. GFM's second search for bare links in the parsed text only runs when some text outside a link could hold one.
  • Running rehype-raw only when needed. It parsed every reply as HTML again. Now it runs only for raw HTML, or for nesting the HTML parser would repair, such as a footnote inside a link label. Otherwise the pipeline repeats the two property changes that round trip made.
  • Two small, upstreamable patches. micromark merges its extension tables once per extensions array instead of on every parse. mdast-util-to-hast collects definitions with a plain loop instead of unist-util-visit.
  • Fast paths in text direction, list-indentation recovery, image line breaks, and the URL-attribute loop.
Over 2,039 real replies (1.9 MiB), production buildBeforeAfter
Full render, best2,080 ms930 ms
Full render, median2,284 ms1,089 ms
Parse only (CommonMark without extensions: 615 ms)1,491 ms706 ms
Real app, opening a 1,114-reply thread: markdown time91 ms40 ms

Parity was checked against the unmodified code with unpatched dependencies:

  • Inputs covered:
    • the real corpus;
    • about 2,000 adversarial cases;
    • 15,000 random documents;
    • 12,698 streaming prefixes.
  • Elements, properties and positions are identical in all of them.
  • The only differences are blank text nodes, which parse5 moved out of tables, and adjacent text nodes that it merged. Both render the same, because hast-util-to-jsx-runtime drops blank text inside table elements.
  • None of the pathological inputs got slower, at 4k to 64k characters. One case got much faster: a long dash line goes from 4.7 s to 7 ms.

Tests compare the renderer with react-markdown (gates, escaped links, BOM, containers, CRLF, streaming through the incremental parser), and compare the shared pipeline with plain rehype-raw. Each fix fails its test when reverted. Codex (GPT 6.1 Sol) and Fable reviewed it in two rounds and found no remaining regressions.

Benchmark and parity scripts with their results: markdown-bench.tar.gz. The corpus is left out because it is private chat history; the README explains how to build one from your own database.

Opus 5.5 via Claude Code

🤖 Generated with Claude Code

Checks

26 successful · 11 skipped
All checks passed
b8638a5

Added 1 commit

  • perf(web): chat Markdown parses about twice as fast with the same outputb8638a5
Synced from GitHub

Approvability

Verdict: Approved at b8638a5

Macroscope's review found this PR approvable — This change reduces Markdown processing work while retaining HTML parsing where needed, sanitization, and URL transformation. Its effects are confined to rendering and Markdown-derived processing, with no product-default or persistent-data changes.

You can add or adjust custom eligibility rules. Learn more.

Synced from GitHub

Thread transfer impact

âś… Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire—5.0 KiB—6.8 KiB✅
CodexThread snapshot wire—3.8 KiB—4.9 KiB✅
CodexLive turn WebSocket wire—1.3 KiB—2.0 KiB✅
CodexLive turn WebSocket decoded—21.6 KiB—24.4 KiB✅
CodexLive turn messages—2—8✅
ClaudeTotal thread wire—5.1 KiB—6.8 KiB✅
ClaudeThread snapshot wire—3.8 KiB—4.9 KiB✅
ClaudeLive turn WebSocket wire—1.3 KiB—2.0 KiB✅
ClaudeLive turn WebSocket decoded—21.9 KiB—24.4 KiB✅
ClaudeLive turn messages—2—8✅

Baseline: unavailable · PR result: b8638a5 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

Synced from GitHub

Review in Change Stack →

No actionable comments were generated in the recent review.

External image Recent review info
External image Run configuration
External image Commits

Reviewing files that changed from the base of the PR and between 9db57770d4e8b48f98835bac31be4fa5af0a0b27 and b8638a5ad233d518ac344921cf5a706625ad4c0a.

External image Files ignored due to path filters: 1
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
External image Files selected for processing: 12
  • apps/web/package.json
  • apps/web/src/components/chat/markdownRenderer.test.tsx
  • apps/web/src/components/chat/markdownRenderer.ts
  • apps/web/src/components/chat/markdownSyntaxGates.ts
  • apps/web/src/textDirection.ts
  • packages/shared/package.json
  • packages/shared/src/markdownListIndentation.ts
  • packages/shared/src/markdownPipeline.test.ts
  • packages/shared/src/markdownPipeline.ts
  • patches/mdast-util-to-hast@13.2.1.patch
  • patches/micromark@4.0.2.patch
  • pnpm-workspace.yaml

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.

Walkthrough

The chat Markdown renderer now gates parser extensions based on document content. The shared rehype pipeline conditionally uses raw HTML parsing. The changes also update Markdown tree processing and text-direction handling.

Changes: Markdown Rendering

Layer / File(s)Summary
Gated chat Markdown parsing
apps/web/src/components/chat/markdownSyntaxGates.ts, apps/web/src/components/chat/markdownRenderer.ts, apps/web/src/components/chat/markdownRenderer.test.tsx, apps/web/package.json, patches/micromark@4.0.2.patch, pnpm-workspace.yaml
The chat renderer applies parser gates for selected GFM constructs and caches merged extensions. Tests compare parser output across Markdown samples and streamed reply prefixes. The workspace pins patched parser versions.
Conditional shared HTML processing
packages/shared/src/markdownPipeline.ts, packages/shared/src/markdownPipeline.test.ts, packages/shared/src/markdownListIndentation.ts, packages/shared/package.json, patches/mdast-util-to-hast@13.2.1.patch
The shared pipeline invokes rehypeRaw when raw HTML or specified nesting is present. Related changes update tree traversal and compare pipeline output with a version that inserts rehype-raw.

Text Direction

Layer / File(s)Summary
RTL-free text handling
apps/web/src/textDirection.ts
The function returns "ltr" for text with Unicode letters but no RTL characters. It returns undefined when the input contains neither.

Priority: External image Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Sequence Diagram(s)

mermaid
1sequenceDiagram
2 participant MarkdownRenderer
3 participant remarkParseWithoutUnusedSyntax
4 participant micromark
5 participant fromMarkdown
6 MarkdownRenderer->>remarkParseWithoutUnusedSyntax: applies the parser attachment
7 remarkParseWithoutUnusedSyntax->>micromark: parses with gated extensions
8 remarkParseWithoutUnusedSyntax->>fromMarkdown: parses with merged mdast extensions

Possibly related issues

  • pingdotgg/t3code#16105: The PR gates GFM strikethrough parsing, but the available summary does not establish a change to the single-tilde rule described by the issue.

Suggested reviewers: t3dotgg, yash-singh1, maria-rcks

Merge Risk: External image Minimal · up to b8638

No actionable regression is established from the supplied evidence; the change appears mergeable after normal checks.

Pre-merge checks: Passed 3 · Failed 1

Failed Failed checks: 1 warning

Check nameStatusExplanationResolution
Description checkWarningThe description follows the template well. It states the problem (uncached replies pay the full parsing pipeline, about 80 ms on large threads) and the change (syntax gates, conditional rehype-raw, tw…Add a Scope and approval section. Link the triaged bug issue or the maintainer-approved discussion, with the approval comment. If no prior issue exists, explain why this performance change qualifies as a very small, focused fix. Also confir…
Passed Passed checks: 3 passed
Check nameStatusExplanation
Title checkPassedThe title states the main change: a performance improvement to web chat Markdown parsing that keeps the same output. The changeset adds syntax gates to the Markdown parser, runs rehype-raw conditional…
Linked Issues checkPassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes checkPassedCheck skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title states the main change: a performance improvement to web chat Markdown parsing that keeps the same output. The changeset adds syntax gates to the Markdown parser, runs rehype-raw conditionally, and adds fast paths, all aimed at faster parsing. The title is also written in conventional commit form with a scope, as the repository template asks. The title is 72 characters, so it is slightly longer than ideal but still readable.

Full details: Description check

Explanation

The description follows the template well. It states the problem (uncached replies pay the full parsing pipeline, about 80 ms on large threads) and the change (syntax gates, conditional rehype-raw, two upstream-style patches, and fast paths). It gives benchmark tables and parity methodology in the Verification area. It also names the model and harness used. Gaps remain against the template. The Scope and approval section has no link to a triaged issue or maintainer-approved discussion, and no explanation for why the change qualifies without one. The description also cites a prior stacked PR but does not show that PR's approval. The Verification section lists test coverage, but the tests and parity scripts sit in an external tarball, not in the repository. The description also does not include a before and after screenshot, though this change is not a UI change.

Resolution

Add a Scope and approval section. Link the triaged bug issue or the maintainer-approved discussion, with the approval comment. If no prior issue exists, explain why this performance change qualifies as a very small, focused fix. Also confirm that stacked PR #27480 is merged or reviewed, or state its status. Keep the verification notes that already exist. Confirm the test names or file paths in the repository so reviewers can run the focused tests without the external tarball.

Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
đź§Ş Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.