Architecture
Two halves, one narrow boundary
Rust does not call Compose. It describes what to draw as plain values, and a Kotlin interpreter on the other side turns them into Compose. Between them is a small C ABI: synchronous, on one thread, with nothing copied. Redwood and Jetpack Glance are built the same way.
Host and renderer
- Host
- The Rust side. It runs your components in a Dioxus
VirtualDom, keeps your state, and for HTML screens computes styles and layout. - Renderer
- Compose Multiplatform compiled ahead of time into a native library, with an interpreter for a fixed set of drawing elements and widgets. It owns the pixels, the text stack, the input method, scrolling and focus.
| Repository | Holds |
|---|---|
| dioxus-compose (this one) | The Dioxus renderer: the adapter from Dioxus to compose-rust, and the HTML path (dioxus_compose::html) |
| compose-rust | Using Compose from Rust: the widget schema, the renderer and its builds, the boundary, the design systems |
The split follows what each part is about. Turning CSS into drawing elements is about Dioxus and HTML, so it lives here; compose-rust never needs to know about it. Drawing those elements is about Compose, so it lives there, and compose-rust's own users get the same elements.
The HTML path
your component (rsx! with div, span, CSS)
-> Dioxus VirtualDom runs it, produces mutations
-> blitz-dom document Stylo resolves CSS, Taffy lays out
text sizes from a TextMeasurer
-> DisplayList boxes in paint order, absolute rectangles
-> Plan, and diff against the last drawing elements nested in their containers
-> renderer (Compose) planned: the bridge is not built yet
Each stage is a value you can inspect, which is how the examples are tested. The
DisplayList is what layout decided: rectangles, colours, borders, radii, shadows,
clips, text runs, fields, images and scroll containers. The Plan is what the
renderer will draw: boxes placed at offsets inside their parents, scroll containers holding
their content, text, fields and images, with colours and brushes registered once. Its
diff names what changed, node by node and modifier by modifier, so a change to one
box's background sends one change.
CSS names and selectors never cross to the renderer. That keeps the renderer small and the same for every app, and it means a CSS feature is added in Rust, in one place.
Two pieces are not built yet. The bridge from the plan to compose-rust's
AbsoluteBox and drawing modifiers is
partial, and so is the synchronous call through which the
Host will ask Compose how big a run of text is during layout
(#43).
The widget path
For a screen written with widget names, the Host turns Dioxus's mutations into fixed-layout records (create, set a property, set a modifier, insert, move, remove, set text, append text) in one buffer per call. The renderer applies the whole buffer inside one Compose snapshot, so a half-applied tree is never drawn.
The boundary
The boundary is a handful of C functions, and adding one is a deliberate decision rather than a convenience. The model is JSI's rather than the old React Native bridge's: same thread, direct calls, synchronous return values.
- Only primitives, pointers and lengths cross. No objects and no
closures, because a Kotlin
ModifierorMutableStatecould never cross a C ABI anyway. - Records are read in place. They are fixed-layout and generated from one Rust schema; there is no JSON, bincode or other serde format on the hot path. Strings are an offset and a length into the same buffer.
- The schema is checked. The Kotlin side's types are generated from the Rust schema, and a hash compared at start-up refuses to run two halves built from different schemas.
- Errors are events. Something the renderer does not recognise becomes a
ProtocolErrorevent, never a crash.
The frame model
- The renderer receives input and calls into the Host on its UI thread.
- The Host runs your handler, diffs, encodes the changes and returns them with the handler's result, for example whether a key was consumed.
- The renderer applies the changes in one snapshot and reports the result to Compose.
- A worker thread that changed a signal causes a frame request. Requests coalesce into one frame on the next tick of Compose's frame clock.
The VirtualDom runs on the renderer's UI thread rather than a thread of its own,
which removes the need for locks and queues. The only cross-thread signal in the design is the
frame request. A frame requested while a handler runs waits until the handler returns, so the
renderer is never re-entered from inside a call.
What runs where
The renderer is built per platform by compose-rust. Where Compose can be compiled for the platform directly, there is no virtual machine in the result.
| Platform | Renderer built as | Carries a Java runtime |
|---|---|---|
| macOS | Kotlin/Native | No |
| Linux | Kotlin/Native | No |
| iOS | Kotlin/Native | No |
| Web | Kotlin/Wasm | No |
| Android | Kotlin on the platform's own runtime | The platform's |
| Windows | GraalVM native image | Yes, compiled in |
Windows is the exception because Compose has no native target there that can be linked with Skia. The application does not know which it got: the boundary, the records and the interpreter are the same on every platform.