Lists & streaming
Long lists and streaming text
A chat client is a very long list plus text that arrives a token at a time. Both now exist on the Rust side with tests behind them, and both are only half the story until the Compose interpreter catches up. This page says precisely which half you can rely on.
LazyColumn (FR-8) and AppendText (FR-9) are Agreed
in the SPEC and implemented in the Host: both have passing acceptance tests in
dioxus-compose/tests/. The Compose interpreter is being finished in parallel;
until it lands, see the renderer gap below for what that means
on screen.
A plain list
A Column with a for loop over your data is correct and is fine for
the hundreds of rows that fit an ordinary screen with some scrollback. It does not
virtualise: every item becomes a real node on both sides.
rsx! {
Column {
fill_max_width: true,
for message in messages() {
Text { text: message }
}
}
}
Updating one message sends exactly one SetProp mutation and does not recompose
its siblings, a tested requirement (FR-4), not an aspiration. The cost of this pattern is
the node count, not the update path.
LazyColumn windowing Host side
A plain tree diff cannot keep a virtualised list virtual: the Host would have to describe items it never built in order for the renderer to know they exist. So for lazy lists the flow inverts.
- The Host declares the total item count and a stable key per item.
- The renderer decides which range is visible and asks for it with a
RangeRequestedevent (event tag 7, 24 bytes). - The Host materialises subtrees for that range plus a buffer on each side, and nothing else.
Each item is wrapped in a Box node carrying an item_key string,
which is what the renderer hands to Compose's LazyColumn as the item key. Scroll
position and item identity stay in the renderer; the data stays in the Host. Scrolling back
to an item you have already seen re-materialises an identical subtree.
rsx! {
LazyColumn {
item_count: messages.len(),
buffer: 4, // items kept on each side; default 4
key_of: move |index| messages()[index].id.clone(), // optional; defaults to the index
item: move |index| rsx! {
Text { text: messages()[index].body.clone() }
},
}
}
item and key_of are Callbacks, so a closure works
directly. item_count is a usize; the wire carries it as an
i64. Only item_count and item are required.
The acceptance criterion is concrete, and it passes: with 10,000 items and a request for 20
visible rows with a buffer of 4, the Host materialises 28 items, proportional to the window,
not to the list. The test is
fr8_node_count_is_proportional_to_the_window.
The Kotlin interpreter currently draws a LazyColumn node as a Compose
Column and never sends a RangeRequested event. The protocol,
the property and the event decoder are all in place, what is missing is the Compose
LazyColumn that drives them. Until that lands, a LazyColumn
renders its initial window and stays there. Write against the component now if you like;
do not ship a 10,000-item screen on it yet.
Streaming text Host side
An LLM response arrives token by token. Replacing the whole text property on
every token re-sends the entire message each time, and the message grows, so the cost grows
quadratically. The answer is an AppendText command on a Text node
(mutation tag 8, 16 bytes) that carries only the new tail.
The Host coalesces: tokens that arrive between two frames merge into a single
AppendText record per node, flushed in render_frame. A token never
costs a batch of its own, and the accumulation buffers are reused so steady-state streaming
does not allocate.
// Queue a tail for the next frame. Repeated calls coalesce into one record
// per node and into a single frame request.
host.append_text(node_id, " token");
append_text takes a protocol node_id, and component code has no
good way to obtain one. It is a boundary-level facility that the tests and the embedder
drive, not something to call from inside a component. Until an rsx!-level
affordance exists, stream by writing to a signal and letting the diff send a
SetProp: correct for short answers, and quadratic for long ones.
Measured on the Host side: a 36 KB message stays under a 64-byte batch per token, and the p99 streaming frame is 125 ns. The full criterion, 100 appends per second with scrolling and typing staying smooth, is an end-to-end claim and has to wait for the renderer.
Streaming from a worker thread
This part is not speculative, and it matters more than either feature above. Domain work must not run on the UI thread, the thread that also runs your components and the whole boundary. PTY reads, HTTP, LLM streaming and file I/O belong on Host worker threads.
A worker's only job, as far as the UI is concerned, is to write to a signal. Waking the
renderer is internal: the Host requests a frame for you, and repeated requests coalesce into
a single render_frame call inside the Compose frame clock. User code never calls
a boundary function, and there is no queue between the two sides to fill up.
Since a handler runs inside the renderer's call into the Host, a frame requested from inside a handler is deferred until that call returns. Re-entering the renderer from a handler is not allowed. Change state and let the frame happen.
The frame budget
The performance target is a comparison, not a number in isolation: the same screen written directly in Kotlin and Compose is the baseline, and this stack is allowed at most 10% more frame time than that, against an 8.33 ms budget on a 120 Hz display. A few of the budgets that shape how you write code:
| Item | Budget (p99, release) |
|---|---|
| Host work: handler + diff + batch encoding | ≤ 0.5 ms for ordinary interaction, ≤ 1 ms for a streaming frame |
| One boundary call | ≤ 100 ns on desktop and iOS, ≤ 200 ns on Android |
| Applying a batch in the renderer | ≤ 0.3 ms per 100 mutations |
| Input to pixels | Same frame count as the baseline; zero extra frames of latency |
| Dropped frames | Zero while streaming 100 tokens/s and scrolling a 10,000-message conversation |
Allocation is measured as a trend rather than an absolute: Dioxus allocates internally while diffing and dispatching (99 allocations per click, measured 2026-09-20), and Rust has no GC, so those allocations do not produce pauses. What matters is that repeating the same interaction does not increase the count. Growth means a leak or a cache that stopped working, and is investigated as a bug.