dioxus-compose guide

Architecture

Two halves, one narrow boundary

Rust does not call Compose. It cannot: GraalVM's @CEntryPoint only passes primitives and word-sized values, so a Modifier or a MutableState could never cross. Instead Rust describes a UI tree as values, and Kotlin runs an interpreter over a fixed schema. Redwood and Jetpack Glance work the same way.

Host and Renderer

the stack
┌──────────────── Host (Rust) ─────────────────┐
 your components (rsx!, hooks, signals)       
 dioxus-core VirtualDom                       
 dioxus-compose renderer: Mutations → bytes   
└──────────────────┬───────────────────────────┘
                    synchronous direct calls on the UI thread
                    + one batch buffer per call
┌──────────────────┴──── Renderer (Kotlin) ────┐
 generated shims (@CEntryPoint / @CName / …)  
 protocol decoder → node table (snapshot)     
 schema interpreter: @Composable RenderNode   
 Compose Desktop (AWT) / Compose iOS (UIKit)  
└──────────────────────────────────────────────┘
Host
The Rust process. Owns your components, your domain logic and the VirtualDom.
Renderer
The AOT-compiled Kotlin/Compose native library, including the schema interpreter.
Schema
The closed set of widget types, properties, modifiers and events the renderer understands.
Mutation
One tree-change command: Create, SetProp, SetModifier, Insert, Move, Remove, SetText, AppendText.
Event
A notification travelling the other way, addressed as (node_id, handler_id, payload).

The C ABI surface

The boundary is deliberately tiny, and adding to it requires a specification change. The model is JSI's, not the old React Native bridge's: same thread, direct calls, synchronous return values.

c: Renderer → Host (exported by Rust)
int32_t dioxus_compose_host_init(const uint8_t* handshake, uint32_t len, MutationBatch* out);
int32_t dioxus_compose_host_dispatch_event(const uint8_t* event, uint32_t len, MutationBatch* out);
int32_t dioxus_compose_host_render_frame(uint64_t frame_time_nanos, MutationBatch* out);
void    dioxus_compose_host_release_batch(MutationBatch* batch);
void    dioxus_compose_host_shutdown(void);
c: Host → Renderer (exported by Kotlin)
int32_t dioxus_compose_renderer_run(void);            /* blocking; LoopMode::Renderer only */
void    dioxus_compose_renderer_request_frame(void);  /* thread-safe */

That is the entire surface. Two rules follow from it:

The handshake exchanges a schema hash, a protocol version and the loop mode. A hash mismatch fails initialisation rather than producing a subtly wrong UI. The Rust status codes are 0 for success and negative values for protocol errors, uninitialised state, double initialisation and caught panics, nothing unwinds across the boundary, because a protocol error has to become a ProtocolError event, never a process abort.

The protocol

Both halves are in one process, so the encoding minimises copying rather than bytes on a wire. Records are fixed-layout and read in place; there is no decode step and no serde-style format on the hot path.

A batch is applied inside a single snapshot transaction, so an intermediate tree state can never be drawn. The renderer applies the batch in the same call stack and releases it immediately; no batch is ever queued.

The measured target for a text-change event is two boundary calls (dispatch_event and release_batch) and at most one heap allocation in the renderer: the Compose String.

The frame model

  1. The renderer receives input and calls dispatch_event on its UI thread.
  2. The Host runs your handler, diffs the VirtualDom, encodes the mutations into the arena and returns the batch plus a result value.
  3. The renderer applies the batch in one snapshot, releases it, and reports the result to Compose, for example as true from onKeyEvent.
  4. Worker threads that changed state cause a frame request; requests coalesce into one render_frame inside the next Compose frame clock tick.

The VirtualDom runs on the renderer's UI thread, not on a thread of its own, which is what removes the need for locks and for a queue. The single piece of cross-thread communication in the whole design is the frame-request wake. A frame requested while a handler is executing is held until the handler returns, so the renderer is never re-entered from inside a dispatch.

Schema codegen

The C ABI only carries bytes, so type safety is layered on top of it. Widgets, properties, modifiers and event payloads are defined once in Rust; the Kotlin types and codecs are generated from that definition, and a checked-in test fails if the generated output is stale. A schema hash compares the two sides at handshake time. Nobody writes JNI or cinterop glue by hand.

The macOS runtime

Window ownership was deliberately left to Compose Desktop's AWT path. Taking it over would mean wiring NSTextInputClient, TSF and ibus/fcitx by hand, exactly the text quality this project refuses to risk. ComposeWindow is an AWT JFrame, and ahead-of-time compilation does not change which code paths run, so the AWT IME path survives into the native image.

Statically linked AWT on macOS still resolves three things by file path, which is why the distribution is a single lib/ directory:

FileWhy it is there
libawt_lwawt.dyliblibawt's initialisation loads it by path. The JNI functions already resolve inside the image, so a placeholder fills the slot.
libjawt.dylibSkiko dlopens it from <java.home>/lib. Ours forwards to JAWT_GetAWT inside the image.
JNI_OnLoad_osxuiA symbol every statically linked JNI library must define; NIK's archive does not, so the build defines it.
libskiko-macos-<arch>.dylibSkia, which Skiko loads by path.
These JNI details are internal to the JDK

java.desktop has always bound Java and native code together with JNI, including when Compose runs on an ordinary JVM. None of it touches the Host↔Renderer boundary, which is pure C ABI. The project's ban on hand-written JNI is about the Android boundary.

One more macOS fact shapes the process: AppKit demands the main thread. The renderer runs on a secondary thread while the main thread creates and runs NSApplication itself, which puts AWT into the embedded mode it uses under SWT or JavaFX hosts. If AWT is allowed to own the loop, it re-enters [NSApp run] and control never returns to the Host after the window closes. Accordingly dioxus_compose_renderer_run must be called on the process main thread; otherwise it returns RUN_NOT_MAIN_THREAD.