Parse a reply's markdown once per composition, not once per delta

Scrolling was laggy, and measuring said where the time went. Instrumenting
the transcript's main-thread work on the emulator, against `/stream 200`:
markdown parsing ran fifty-eight times in three seconds -- once per streamed
delta, each one re-parsing the whole message the reply had grown into -- for
49-78ms of main-thread work per three seconds, with single parses reaching
7.3ms. That is most of a frame at 60Hz and more than a whole one at 120.
Everything else the transcript does per event was under a tenth of it.

So only the first parse stays on the composing thread. That one has to: the
renderer's asynchronous path draws an empty loading slot until its result
arrives, which measures a row at nothing before it is measured at its real
height, and the whole transcript above it collapses and springs back. Every
parse after the first is the same row growing, and there is a previous parse
to keep drawing until the new one lands -- so those go to a background
thread and no frame is ever without a height. What is on screen stays a real
prefix of the reply rather than a guess at it; it is simply one parse behind.

The same measurement found `loaded` costing an ArrayList copy per event, and
nothing reading it. It recorded every event the screen had ever seen against
the possibility that a page arriving in front of them would need the events
themselves to stitch on -- but `joinPages` heals the boundary from the folded
rows and has since it was written, so this was a list that only ever grew.

Checked on the emulator with ui-trace at 1kHz. Streaming at the newest end:
the row's bottom edge holds at y=1940 while it grows upward, and the header,
status row and composer do not move for six seconds. Scrolled back with a
reply streaming: nothing moves at all, 0 of 45 elements over five seconds.
Scrolling a mixed transcript: rows keep a constant height as they translate,
so none of them arrives blank and fills in afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-30 13:24:55 -04:00
1 parent 30ebf4e25c
commit 62c22e38b7
2 files changed
+36 -23

No files matched your search

@@ -2,6 +2,8 @@ package com.example.aiapp
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextLinkStyles
@@ -11,7 +13,10 @@ import androidx.compose.ui.unit.TextUnit
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.parseMarkdown
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* An assistant's reply, rendered as the markdown it is written in.
@@ -26,20 +31,7 @@ import com.mikepenz.markdown.model.parseMarkdown
@Composable
fun MarkdownText(text: String, modifier: Modifier = Modifier) {
val body = MaterialTheme.typography.bodyLarge
// Parsed here, in the composition, rather than by the overload that takes the text itself.
// That one parses in a coroutine and draws an empty loading slot until the result arrives --
// so a row is measured at nothing before it is measured at its real height, and the
// transcript above it collapses and springs back. Seen with five replies on screen at once,
// every one of them blank, the whole conversation shrunk to fit a single screen; a moment
// later it was all there again. That is the "skipping up and down" this list must never do,
// and no amount of scroll anchoring can survive a row that lies about its height first.
//
// The cost is the parse on the main thread, which is the trade being made deliberately: a
// few milliseconds of work at the moment a row is composed, against a layout that is wrong
// every time one is. If a very long reply ever makes that visible, the answer is the
// renderer's streaming state -- which parses incrementally -- and not going back to a
// placeholder with no height.
val parsed = remember(text) { parseMarkdown(text) }
val parsed = parsedMarkdown(text)
Markdown(
parsed,
colors =
@@ -112,3 +104,33 @@ fun MarkdownText(text: String, modifier: Modifier = Modifier) {
modifier = modifier,
)
}
/**
* [text] parsed: on the composing thread the first time this row is drawn, and off it every time
* afterwards.
*
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
* height, and the transcript above it collapses and springs back. Seen with five replies on screen
* at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
* later it was all there again. That is the "skipping up and down" this list must never do, and no
* amount of scroll anchoring can survive a row that lies about its height first.
*
* Every parse *after* the first is a different case, and it is the one that was costing: a reply
* arrives as hundreds of deltas, each one re-parsing the whole message it has grown into. Measured
* against `/stream 200` on the emulator, that was fifty-eight parses and 78ms of main-thread work
* in three seconds, with single parses reaching 7ms -- most of a frame at 60Hz and more than one at
* 120. Those go to a background thread, and the row keeps drawing the parse it already has until
* the new one lands, so there is never a frame without a height. What is on screen is always a
* real prefix of the reply rather than a guess at it; it is simply one parse behind.
*/
@Composable
private fun parsedMarkdown(text: String): State {
// The text each parse came from, so the first composition's is not immediately repeated.
val parsed = remember { mutableStateOf(text to parseMarkdown(text)) }
LaunchedEffect(text) {
if (parsed.value.first == text) return@LaunchedEffect
parsed.value = text to withContext(Dispatchers.Default) { parseMarkdown(text) }
}
return parsed.value.second
}
@@ -595,9 +595,6 @@ fun SessionScreen(
// screen starts with the end of the conversation and fetches earlier
// pages only when somebody scrolls to them.
var oldestSeq by remember { mutableLongStateOf(0L) }
// Every transcript event loaded, in order, beside the rows they folded
// into. See `apply`.
var loaded by remember { mutableStateOf(listOf<SessionEvent>()) }
// Sent, but not yet read by the session -- which is when the backend
// records it and it comes back as a row. Until then it is drawn below
// the working indicator, because that is where it is in the session's
@@ -680,11 +677,6 @@ fun SessionScreen(
if (event is SessionEvent.CommandSent) {
waitingCommands = waitingCommands.filterNot { it.first == event.id }
}
// Kept as well as folded. Folding is one-way -- a tool's
// start and end become one row -- so a page arriving in
// front of what is already here cannot be stitched on
// without the events themselves.
loaded = loaded + event
items = foldEvent(items, entry)
}
@@ -824,7 +816,6 @@ fun SessionScreen(
// screen -- `apply` refills them, and scrolling
// up pages the rest back in as it always does.
items = listOf()
loaded = listOf()
held = listOf()
oldestSeq = 0L
moreHistory = true