perf(ui): memoize per-message HTML to fix mobile chat jank - #458
Conversation
…arses The conversation `message_groups` memo re-ran `group_messages` over the entire visible history whenever `ROOMS` changed — i.e. on every sync tick, incoming message, reaction, and DM. Each pass ran a full markdown parse + HTML serialize + mention/anchor rewrite for *every* message (default cap 100). That is tens of µs per message natively and several-fold more in WASM on a mobile CPU, so a busy room burned ~50-90ms of main-thread time on every update — the "really slows down the mobile browser" report from try.freenet.org. Add a per-message HTML cache (`MESSAGE_HTML_CACHE`, thread-local) keyed by `MessageId` plus a fingerprint of every input that affects the rendered body (effective text always; member-name map + local member id only when the text carries a mention token). Steady-state message flow now re-renders only the one new/changed message instead of the whole history; the cache is pruned each render to the currently-visible set so it stays bounded to the current room. Output is byte-identical to the uncached path — the cache only skips redundant re-rendering. Measured (native micro-benchmark, 100 typical messages): the per-update body-render work drops from re-parsing all 100 (~1.4ms) to 1 parse + 99 String clones (~0.008ms), roughly two orders of magnitude on that hot path; the eliminated work is proportionally larger in WASM on a mobile CPU. Tests: cache output equals the uncached renderer across cache hits and content edits; a member rename invalidates a cached mention chip; mention-free bodies survive member churn (the fingerprint gate); prune bounds the cache to the visible set. UI-only change (no common/contract/delegate WASM) — no migration needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4osQzsfdodxQSS5JY1RqJ
Review — external model (Codex)Ran
This is the correctness contract I care about most here, since the cached HTML flows through Left as a draft — not requesting merge. [AI-assisted - Claude] |
Publishes the per-message HTML memoization fix (#458) to the River UI contract. UI-only change; delegate and room-contract WASM unchanged (verified byte-identical), so no migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4osQzsfdodxQSS5JY1RqJ
Problem
A user on mobile (via try.freenet.org) reported River "really slows down the mobile browser."
Root cause is in the conversation render path. The
message_groupsuse_memore-runsgroup_messagesover the entire visible message history whenever theROOMSsignal changes — andROOMSchanges on every sync tick, incoming message, reaction, and DM. Each pass renders every message body from scratch: a full markdown parse (markdown::to_html_with_optionswith GFM) + HTML serialize + mention-chip resolution + anchor rewriting, for up tomax_recent_messages(default 100) messages.That is tens of µs per message natively and several-fold more in WASM on a mobile CPU. In a busy room it burns roughly 50–90 ms of main-thread time on every update, several times a second — exactly the sustained jank the user described.
Approach
Add a per-message rendered-HTML cache (
MESSAGE_HTML_CACHE, athread_local— safe in single-threaded WASM), keyed byMessageIdplus a fingerprint of every input that determines the body HTML:rv:reference scheme, so ordinary messages survive member joins/renames in the cache while a message that renders a@-chip is correctly invalidated by a rename).running_behind_freenet_gateway()is constant per session, so it is not part of the key.Steady-state message flow now re-renders only the one new/changed message instead of the whole history. The cache is pruned each render to the currently-visible set, so it stays bounded to the current room's messages (and clears on room switch). The cached output is byte-identical to the uncached path — the cache only skips redundant re-rendering, it never changes what is produced (important because this HTML flows through
dangerous_inner_html).Alternatives considered: widening the memo's granularity / per-message child components would be a larger refactor of the render tree; the cache is a localized, low-risk change that captures the dominant win.
Measured
Native micro-benchmark, 100 typical chat messages, per
ROOMS-update body-render cost:Stringclones)~2 orders of magnitude on that hot path; the eliminated work is proportionally larger in WASM on a mobile CPU, where it is the actual main-thread blocking.
Testing
cargo test -p river-ui --bins(504 passed). New tests inconversation.rs:cached_message_html_matches_uncached_across_input_changes— cache hit and content-edit both equal the uncached renderer.cached_message_html_reflects_member_rename_for_mentions— renaming a mentioned member invalidates the cached chip.message_html_fingerprint_gates_member_inputs_on_mentions— mention-free bodies ignore member changes; mention bodies depend on them.prune_message_html_cache_bounds_to_visible_set— pruning drops off-screen entries, keeps visible ones.Also verified
cargo check -p river-ui --target wasm32-unknown-unknown --features no-synccompiles.Scope / notes
ui/src/components/conversation.rs); nocommon/, contract, or delegate WASM touched — no migration needed.perf/reduce-ui-wasm-size/fix-wasm-perfbranches for that). Smaller follow-up runtime candidates I noticed while here: the reply-previewstrip_markdownand the per-message author-nickname decrypt ingroup_messagesalso run per render and could be memoized similarly.[AI-assisted - Claude]
🤖 Generated with Claude Code