perf(web): revisiting a thread reuses its rendered Markdown instead of parsing again#27480
Every time a reply mounts, react-markdown parses it again: when you switch threads, when a row scrolls back into view, or when a turn fold opens. On long threads this made Markdown the second-largest render cost when switching threads, after ChatView.
This PR swaps the <ReactMarkdown> component for a small renderer in chat/markdownRenderer.ts. It keeps the same engine (unified, micromark and our remark/rehype plugins) and produces the same output. The differences:
- One frozen processor per plugin set. Previously a new one was built for every message.
- Finished replies are cached as React elements, up to about 10 MB (500K source characters). Least recently used entries are dropped first. A reply that would take more than a quarter of the cache isn't cached, so a single huge message can't push the others out.
- Streaming text and the incremental-parsing plugin skip the cache and render as before.
Measured on a production build with real threads (389 and 1,114 replies) copied from a live database:
| Markdown parse time | Before | After |
|---|---|---|
| 10 switches between two large threads | 151–166 ms | 64 ms (only first visits parse; revisits 0) |
| Scrolling a long thread | 22 ms | 3 ms |
| Streaming a reply | ~290 ms | unchanged |
A first visit to a thread still parses everything; only a faster engine would help that.
A new test renders a set of cases with both the new renderer and <ReactMarkdown> and checks the markup matches. The cases cover raw HTML, <details>, scripts, tables, task lists, alerts, code meta, footnotes and URL transforms. Another test checks that rendering one oversized message keeps the rest of the cache.
Opus 5.5 via Claude Code
🤖 Generated with Claude Code
Checks
Added 1 commit
- perf(web): revisiting a thread reuses its rendered Markdown instead of parsing again9db5777
Approvability
Verdict: Approved at 9db5777
Macroscope's review found this PR approvable — Completed Markdown rendering is reused through a bounded in-memory cache, while streaming and instance-specific plugins avoid cache insertion. Existing sanitization and URL handling remain in place, with no persistent-data or product-default changes.
You can add or adjust custom eligibility rules. Learn more.
Thread transfer impact
✅ Thread transfer remains within every enforced ceiling.
| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |
|---|---|---|---|---|---|---|
| Codex | Total thread wire | 5.0 KiB | 5.0 KiB | −6 B (−0.1%) | 6.8 KiB | ✅ |
| Codex | Thread snapshot wire | 3.8 KiB | 3.8 KiB | 0 B (0.0%) | 4.9 KiB | ✅ |
| Codex | Live turn WebSocket wire | 1.3 KiB | 1.2 KiB | −6 B (−0.5%) | 2.0 KiB | ✅ |
| Codex | Live turn WebSocket decoded | 21.6 KiB | 21.6 KiB | 0 B (0.0%) | 24.4 KiB | ✅ |
| Codex | Live turn messages | 2 | 2 | 0 (0.0%) | 8 | ✅ |
| Claude | Total thread wire | 5.0 KiB | 5.1 KiB | +6 B (+0.1%) | 6.8 KiB | ✅ |
| Claude | Thread snapshot wire | 3.8 KiB | 3.8 KiB | 0 B (0.0%) | 4.9 KiB | ✅ |
| Claude | Live turn WebSocket wire | 1.3 KiB | 1.3 KiB | +6 B (+0.5%) | 2.0 KiB | ✅ |
| Claude | Live turn WebSocket decoded | 21.9 KiB | 21.9 KiB | 0 B (0.0%) | 24.4 KiB | ✅ |
| Claude | Live turn messages | 2 | 2 | 0 (0.0%) | 8 | ✅ |
Baseline: 32ecd71 · PR result: 9db5777 · 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.
Important
Review skipped
Review was skipped as selected files did not have any reviewable changes.
External image Files ignored due to path filters: 1
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
External image Run configuration
- Source mode: Auto
- Repository: pingdotgg/t3code
- Branch: t3/optimize-markdown-rendering
- File: .coderabbit.config.ts
- Commit: 9db5777
- Layers: Repository TS config
- Review profile: CHILL
- Plan: Team
- Run ID:
221084f9-7360-4014-b12c-afb4e68c16b9
External image Commits
Reviewing files that changed from the base of the PR and between 9db57770d4e8b48f98835bac31be4fa5af0a0b27 and 9db57770d4e8b48f98835bac31be4fa5af0a0b27.
External image Files ignored due to path filters: 1
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.
Use the checkbox below for a quick retry:
- 🔍 Trigger review
No actionable comments were generated in the recent review.
External image Recent review info
-
External image Run configuration
- Source mode: Auto
- Repository: pingdotgg/t3code
- Branch: t3/optimize-markdown-rendering
- File: .coderabbit.config.ts
- Commit: 9db5777
- Layers: Repository TS config
- Review profile: CHILL
- Plan: Team
- Run ID:
16058c7b-361f-4a05-b87c-539b4a0c2051
External image Commits
Reviewing files that changed from the base of the PR and between 32ecd71726fee16f335484c1f95cbce126d461f6 and 9db57770d4e8b48f98835bac31be4fa5af0a0b27.
External image Files ignored due to path filters: 1
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
External image Files selected for processing: 4
apps/web/package.jsonapps/web/src/components/ChatMarkdown.tsxapps/web/src/components/chat/markdownRenderer.test.tsxapps/web/src/components/chat/markdownRenderer.ts
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
Chat Markdown now renders through a configurable renderer instead of
ReactMarkdown. The renderer applies URL transforms and caches rendered elements. Chat rendering selects shared or per-instance plugin lists and enables caching for completed, non-streaming text.Changes: Chat Markdown rendering
Layer / File(s) Summary Renderer, cache, and validation
apps/web/package.json,apps/web/src/components/chat/markdownRenderer.ts,apps/web/src/components/chat/markdownRenderer.test.tsxAdds renderer dependencies and createMarkdownRenderer. The renderer processes Markdown, transforms applicable URL attributes, and caches rendered elements within an estimated-size limit. Tests compare output withReactMarkdownand check cache reuse.Chat rendering integration
apps/web/src/components/ChatMarkdown.tsxMoves URL handling to module scope and routes Markdown through the new renderer. It reuses renderers for shared plugin lists and passes caching flags for completed text. Priority: External image Normal
Estimated code review effort: 3 (Moderate) | ~25 minutes
Change: Refactor
Sequence Diagram(s)
mermaid1 sequenceDiagram 2 participant ChatMarkdown 3 participant MarkdownSource 4 participant createMarkdownRenderer 5 ChatMarkdown->>MarkdownSource: Pass text, plugin settings, and sharing flags 6 MarkdownSource->>createMarkdownRenderer: Create a renderer when plugins are not shared 7 createMarkdownRenderer-->>MarkdownSource: Return a renderer 8 MarkdownSource->>createMarkdownRenderer: Render text with the cache setting 9 createMarkdownRenderer-->>MarkdownSource: Return a React element Suggested reviewers:
maria-rcks
Merge Risk: External image Minimal · up to 9db57
No actionable issue is established; the change is mergeable subject to normal checks.
Pre-merge checks: Passed 3 · Failed 1
Failed Failed checks: 1 warning
Check name Status Explanation Resolution Description check Warning The description follows the template's main sections in substance. The Problem section is covered by the opening paragraph, which explains that Markdown reparses on every mount and was the second-larg… Add a Scope and approval section. Link the triaged bug issue or the maintainer-approved discussion, including the approval comment. If no prior issue exists, explain why this change qualifies as a very small, focused fix of an obvious bug. … Passed Passed checks: 3 passed
Check name Status Explanation Title check Passed The title names the main change: revisiting a thread reuses cached rendered Markdown instead of parsing again. The changeset adds a markdown renderer with a per-message cache of rendered React element… Linked Issues check Passed Check skipped because no linked issues were found for this pull request. Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request. Full details: Title check
Explanation
The title names the main change: revisiting a thread reuses cached rendered Markdown instead of parsing again. The changeset adds a markdown renderer with a per-message cache of rendered React elements, which matches the title. The title uses the conventional commit prefix
perf(web):and is specific enough for a teammate scanning history. It is 84 characters, which is longer than ideal but still readable.
Full details: Description check
Explanation
The description follows the template's main sections in substance. The Problem section is covered by the opening paragraph, which explains that Markdown reparses on every mount and was the second-largest render cost when switching threads. The Change section explains the renderer swap, the frozen processors, the cache limits, and the streaming bypass. Verification includes measured timings from a production build, and it lists the new parity and cache-eviction tests. The Scope and approval section is missing. It does not link a triaged bug issue or maintainer discussion, and it gives no justification for skipping one. The description also lacks the required before/after evidence framing for UI-adjacent behavior, although the change is primarily performance-related and the measurement table partly covers this. The AI-attribution line is present.
Resolution
Add a Scope and approval section. Link the triaged bug issue or the maintainer-approved discussion, including the approval comment. If no prior issue exists, explain why this change qualifies as a very small, focused fix of an obvious bug. Also confirm that the Verification section states what was not checked, such as first-visit behavior in other clients or browsers, if applicable.
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.