镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

perf(messages): keep recent markdown parses across row remounts - #1967

Merged
kalvinnchau merged 1 commit into
mainfrom
peon/markdown-cache
Oct 30, 2026
Merged

kalvinnchau merged 1 commit into
mainfrom
peon/markdown-cache

Conversation

@kalvinnchau

@kalvinnchau kalvinnchau commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Switching back to a channel no longer prepares and parses every unchanged message body again.

Only one channel reader is mounted at a time, so each switch remounts every visible row. MessageMarkdown memoized prepareMarkdown and the react-markdown parse per mount, so it redid both for content the app had already rendered. It now keeps recent results in two module-level maps keyed by content: prepared bodies by row.content, and parsed element trees by the protected content.

Each map has a weight budget, and a result heavier than the whole budget is not kept. Measured with production React, a parsed body keeps about 2 KB, plus about 1 KB per element and about 2 bytes per character. Parses are therefore weighed by element count and key length, with a budget of about 4 MB. That holds for dense markup (10k characters of *a* is 2.5k elements and 2.7 MB) and for long plain paragraphs (99k characters is 155 KB). Prepared bodies keep only their content and literal ranges, so they are weighed by length, up to 200k characters.

The parsed elements depend only on the protected content. The components they render still read the rendering row's context, so links, mentions, channel names and spoilers come from the row that mounts them. react-markdown's default export is a hook-free function, so calling it directly keeps its result without changing what renders.

Chromium CPU profile over 12 switches at 100 channels: Markdown 69.5 → 3.6 ms, and prepareMarkdown is no longer sampled.

Blox workstations, main 561265d against this commit: 5 Linux and 5 macOS, production builds, Playwright WebKit 26.6 and Chromium 153. The fixture has 100 channels with 10 thread tabs each, 200 history rows, 100 thread replies and background traffic. Each machine ran every cell once per variant, interleaved with this change stacked on #1966.

Switch latency over 5 runs. Switching between two channels gives 55 samples. Switching around 10 open channels in turn gives 95 samples, and each body has to survive nine other channels in the cache:

Linux before Linux after Linux Δ macOS before macOS after macOS Δ
WebKit: switch p50 (ms) 429 424 -1% 128 118 -8%
Chromium: switch p50 (ms) 125 109 -13% 76 73 -4%
Chromium: script per switch (ms) 105 92 -13% 63 57 -9%
WebKit, 10-channel cycle: switch p50 (ms) 495 476 -4% 142 134 -6%
Chromium, 10-channel cycle: switch p50 (ms) 128 115 -11% 79 76 -4%
Chromium, 10-channel cycle: script per switch (ms) 110 98 -12% 66 61 -8%

Linux WebKit (WebKitGTK) two-channel switching varies from run to run. Per-machine medians for this commit range from 405 to 450 ms. Two earlier rounds of the same parse cache measured −8% (434 → 400) and −7% (435 → 405) there.

Medians of 5 runs over a 20 s idle window with background traffic. This change is not expected to move them:

Linux before Linux after Linux Δ macOS before macOS after macOS Δ
WebKit: frame overrun (ms) 658 572 -13% 1346 1336 -1%
WebKit: frames over 50 ms 0 0 +0 1 1 +0
WebKit: p99 frame (ms) 36 34 -6% 26 25 -4%
Chromium: script (ms) 173 176 +2% 321 332 +4%
Chromium: task (ms) 900 913 +1% 2247 2270 +1%

Earlier rounds of the same parse cache, whose weights differed but measured the same, covered three more cases:

  • Traffic in the open channel: WebKit switch −6% on Linux and −6% on macOS; Chromium switch −13% and −3%.
  • Switching with no thread tabs open: WebKit 231 → 221 ms and 77 → 74 ms. Chromium script per switch 51 → 49 ms and 40 → 38 ms.
  • Keystroke, sidebar hover and Tab focus to frame: p50 within 1 ms in both engines on both systems.

Validation:

  • MessageMarkdown.test.tsx has four new tests:
    • A remounted body reuses its parse while each row shows its own channel names.
    • A body is parsed again once newer bodies outweigh the budget.
    • Long single-paragraph bodies are weighed by their length.
    • A body heavier than the whole cache is not kept and does not evict older ones.
      Three fail on main. The length test passes on main, which parses every time, and fails with the length weight removed. The heavy-body test also fails with the guard removed.
  • pnpm check, and Vitest for src/features/messages (2212/2212).
  • Playwright in Chromium and WebKit: messages, mentions, composer-links, channel-tabs, terminal-renderer, navigation-thread-history (101 passed; 1 skipped by design in Chromium).
  • Full Vitest: 13,945 passed, 20 skipped.

🤖 Generated with Claude Code

switching channels remounts every message row, so revisiting a channel
prepared and parsed every unchanged body again. keep recent
prepareMarkdown results and react-markdown element trees keyed by the
protected content. weigh parses by element count and key length, and
skip any result heavier than its budget, so each cache stays near 4 mb
even for dense markup or long bodies. the parsed elements depend only on
that content; their components still read the rendering row's context.

Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
@kalvinnchau
kalvinnchau marked this pull request as ready for review October 30, 2026 14:55
@kalvinnchau
kalvinnchau requested a review from a team as a code owner October 30, 2026 14:55

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

No blockers at 812c123. 9/10. The parse cache key covers every ProtectedContent field, and the other parser inputs are module constants. Row, session and channel behavior stays behind the context-reading component types, so cached trees don't carry bindings between rows. LRU accounting is consistent across hits, eviction and oversized results. With react-markdown 10.1.0 pinned, the default export is synchronous and hook-free (runSync), and an async plugin still throws as it did before.

A production-React Node probe agreed with the budget estimate: a 99k-character paragraph retained about 155 KB, roughly 3 MB at the full budget. That is a plausible estimate, not a heap ceiling. Prepared results and live trees are on top of it.

The two inline comments are optional. Hosted checks are green, and no suites were rerun locally.

protectedContent.prefix,
protectedContent.parts,
]),
// The default export is a hook-free function: calling it keeps its result.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Optional: tie this to the pinned react-markdown version (10.1.0). A cache miss calls Markdown and a hit doesn't. If a future default export used hooks, hook order would differ between those renders, and nothing here would warn an upgrader.

expect(screen.getByText(":party_parrot:")).toBeInTheDocument();
});

it("reuses a remounted body's parse while each row reads its own inputs", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Optional: nothing observes the preparedBodies cache. These tests would still pass with that wrapper removed. Counting prepareMarkdown calls in this remount test would cover it without a new test.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

No blocking findings at 812c123. The cache key covers everything the parse reads: content, prefix and parts, plus module-constant plugins, transformUrl and markdownComponents. Per-row inputs still come from MessageComponentsContext at render time.

What we checked, beyond hosted CI (green):

  • react-markdown 10.1.0's sync export is hook-free.
  • StrictMode probes with one shared cached tree: each mount keeps its own state, provider updates arrive through bailouts, and a remount gets fresh state, all with one parse.
  • A parse that throws reaches the error boundary and leaves nothing cached.
  • MessageMarkdown.test.tsx passes in shuffled order (seeds 1–5), and each new test passes alone.

Validation gaps (not blockers):

  • The ~4 MB budget is a production heuristic and no test measures retained heap.
  • In development builds, cached elements keep _owner/_debugStack, so dev heap can exceed it.

Two optional test-coverage notes are inline.

...actual,
// Hook-free, so the returned elements are the parse a caller keeps.
default: (properties: ComponentProps<typeof actual.default>) => {
markdownRenders();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: markdownRenders now counts cache misses, not MarkdownBody renders. That means the existing "without reparsing" assertions (:338, :350) no longer catch a memo regression.

We checked this by removing memo from MarkdownBody and adding directory to the protectedContent deps. Base fails (called 2 times, expected 1); this head passes. The cost would only be performance: protectInlineContent plus a JSON.stringify on every directory or profile update, and React still bails out on the same element.

To keep that coverage, count MarkdownBody renders separately from parses. Otherwise, accepting the loss is fine.

expect(screen.getByText(":party_parrot:")).toBeInTheDocument();
});

it("reuses a remounted body's parse while each row reads its own inputs", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this test varies only context inputs (channel names), not inputs that come from the key. If parts is dropped from the parsedBodies key, only an unrelated test fails (the agent icon at :892). The result would be two rows with the same text but different bound pubkeys sharing a tree, so one row's mention would open the other's profile.

This is an identity-binding invariant, so a direct guard is worth having. In this same test, render one body as two rows bound to different pubkeys under the same display name, and assert each one opens its own data-profile-target.

@kalvinnchau
kalvinnchau merged commit a1faeb6 into main Oct 30, 2026
37 checks passed
@kalvinnchau
kalvinnchau deleted the peon/markdown-cache branch October 30, 2026 16:10

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

No blockers at 812c123. Sharing parsed elements across rows is sound. The cache key (content, prefix, parts) covers every input the remark/rehype pipeline reads. transformUrl and markdownComponents are module constants, and per-row behavior (links, mentions, spoiler state, code renderers) still comes from MessageComponentsContext at render time. react-markdown 10.1.0's sync Markdown export is hook-free (MarkdownHooks is the hooked one), so calling it directly is safe at this version.

Local checks: MessageMarkdown.test.tsx passes (109/109), including three shuffled runs (seeds 1 to 3), so the module-level cache doesn't make the tests order-dependent. CI is green.

One optional note is inline.

render("**evicted**");
const words = "word ".repeat(19_000);
for (let index = 0; index < 25; index++) render(`${index} ${words}`);
const newer = markdownRenders.mock.calls.length;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, test cost: locally this test takes ~1.4 s and the heavy-body test ~0.4 s, against ~2.8 s for the rest of the file. The cost comes with the approach. Overflowing a 4,000-unit budget by length alone takes about 2M characters of parsing, and MAX_MARKDOWN_LENGTH caps each body at 100k, so you need at least 21 near-cap parses. As the PR notes, this one also passes on main, so it guards a tuning constant rather than behavior. Fine to keep. If file time matters, you could check the weight function directly in Node instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants