Skip to content

perf(ui): memoize per-message HTML to fix mobile chat jank - #458

Merged
sanity merged 1 commit into
mainfrom
perf/message-html-memo
Jul 23, 2026
Merged

perf(ui): memoize per-message HTML to fix mobile chat jank#458
sanity merged 1 commit into
mainfrom
perf/message-html-memo

Conversation

@sanity

@sanity sanity commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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_groups use_memo re-runs group_messages over the entire visible message history whenever the ROOMS signal changes — and ROOMS changes 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_options with GFM) + HTML serialize + mention-chip resolution + anchor rewriting, for up to max_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, a thread_local — safe in single-threaded WASM), keyed by MessageId plus a fingerprint of every input that determines the body HTML:

  • the effective text (always),
  • the member-name map + local member id (only when the text carries a mention token — gated on the 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:

cost / update
before (re-parse all 100) ~1.4 ms
after (1 parse + 99 String clones) ~0.008 ms

~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 in conversation.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-sync compiles.

Scope / notes

  • UI-only (ui/src/components/conversation.rs); no common/, contract, or delegate WASM touched — no migration needed.
  • Separate, not addressed here: the 5.7 MB UI WASM bundle is a large initial-load cost on mobile (there are already perf/reduce-ui-wasm-size / fix-wasm-perf branches for that). Smaller follow-up runtime candidates I noticed while here: the reply-preview strip_markdown and the per-message author-nickname decrypt in group_messages also run per render and could be memoized similarly.

[AI-assisted - Claude]

🤖 Generated with Claude Code

…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
@sanity

sanity commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Review — external model (Codex)

Ran codex review --base main. Clean pass, no actionable findings.

The cache includes all mutable rendering inputs, invalidates edited and mention-bearing messages appropriately, and remains bounded through pruning. No actionable correctness regressions were identified.

This is the correctness contract I care about most here, since the cached HTML flows through dangerous_inner_html: the cache returns byte-identical output to the uncached renderer for the current inputs (pinned by cached_message_html_matches_uncached_across_input_changes + cached_message_html_reflects_member_rename_for_mentions), and the rv: mention gate keeps mention-free bodies valid across member churn while invalidating rendered chips on rename.

Left as a draft — not requesting merge. cargo test -p river-ui --bins green (504), wasm target compiles.

[AI-assisted - Claude]

@sanity
sanity marked this pull request as ready for review July 23, 2026 18:09
@sanity
sanity merged commit 907d4b8 into main Jul 23, 2026
6 checks passed
sanity added a commit that referenced this pull request Jul 23, 2026
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
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.

1 participant