Bevy-React Render Protocol — a compact little-endian batch format for the hot React → Bevy mutation path (CreateNode, UpdateNode, AppendChild, …).
BRRP sits beside the in-process enum RPC (ReactClientProto over mpsc): same semantics, denser framing. When the host registers __react_commit_ops (binary_ops feature), the TS reconciler auto-detects it and uses BRRP by default.
| Piece | State |
|---|---|
| Schema + design notes | This doc |
Rust encode/decode (plugin/src/react/proto/) |
Landed; unit-tested (inline + string table) |
Cargo feature binary_ops |
Registers __react_commit_ops |
TS encode/decode (packages/bevy-react/src/protocol.ts) |
Landed; golden-byte + round-trip tests |
| TS reconciler binary commit path | Default when __react_commit_ops exists |
| Soak / integration | TS reconciler soak + plugin/tests/binary_ops_soak.rs |
Host (registers __react_commit_ops):
cargo build --manifest-path plugin/Cargo.toml --features binary_ops
# examples: features = ["binary_ops"] on the bevy_react dependencyJS reconciler — no opt-in needed when the host has the feature:
Priority:
1. createBevyApp({ binaryOps }) / createBevyReconciler({ binaryOps })
2. globalThis.__BEVY_REACT_BINARY_OPS when set
(true | 1 | "1" → on; any other value → off)
3. typeof __react_commit_ops === "function" → on
4. else → enum natives
createBevyApp(<App />, { binaryOps: false });
// or
globalThis.__BEVY_REACT_BINARY_OPS = 0; // also false / "0"createBevyApp(<App />, { binaryOps: true });
// or
globalThis.__BEVY_REACT_BINARY_OPS = 1;When binary is active, mutation RPCs are queued during the React commit and flushed once via __react_commit_ops(Uint8Array) from resetAfterCommit. Node ids are allocated on the JS side; the host advances its id counter so a later enum-path alloc cannot collide.
Ops are tiny, fixed-shape, and frame-scoped. A hand-rolled LE codec avoids prost/protobuf deps, keeps zero-copy-friendly layouts later, and matches Fabric-style “mutation instructions in a buffer” more closely than a general IDL.
All multi-byte integers are little-endian.
Frame {
magic: u32 // ASCII "BRRP" on the wire (B at low byte) = 0x50525242
version: u16 // 1
flags: u16 // bit0 = FLAG_STRING_TABLE; other bits rejected
[table: { // only when FLAG_STRING_TABLE
count: u32
entries: String[count] // index 0 reserved / unused
}]
root_id: String | StringRef
op_count: u32
ops: Op[op_count]
}
String {
len: u32
utf8: u8[len]
}
StringRef = u32 index // 0 ⇒ inline String follows; else table[index]
root_id is written once per frame and expanded onto each decoded ReactClientProto message.
| Code | Name | Payload |
|---|---|---|
0x01 |
CreateNode | node_id:u64, `node_type:String |
0x02 |
CreateText | node_id:u64, `content:String |
0x03 |
AppendChild | parent_id:u64, child_id:u64 |
0x04 |
InsertBefore | parent_id:u64, child_id:u64, before_id:u64 |
0x05 |
RemoveChild | parent_id:u64, child_id:u64 |
0x06 |
UpdateNode | node_id:u64, `props_json:String |
0x07 |
UpdateText | node_id:u64, `content:String |
0x08 |
DestroyNode | node_id:u64 |
0x09 |
ClearContainer | (none) |
0x0A |
Commit | (none) → ReactClientProto::Complete |
Props remain JSON strings in v1 (style conversion still parses JSON). A later revision can intern style keys or send typed prop bags.
One BRRP frame = one React commit batch for a single root:
- Reconciler encodes all mutations for the commit into one buffer (plus trailing
Commit). - Host decodes and enqueues
ReactClientProtomessages in order. - Trailing
Commit(0x0A) maps toComplete(same marker the enum path already uses).
The Bevy render system still drains the channel per frame; BRRP does not yet introduce a separate apply barrier beyond Complete.
When flags & FLAG_STRING_TABLE is set, the frame includes a string table after flags, and every string field is a StringRef (u32 index). Index 0 is reserved as an inline escape (0 + following String).
encode_batch_with(root, &ops, EncodeOptions { string_table: true })?;encodeBatch(rootId, ops, { stringTable: true });The reconciler binary path currently uses inline strings (flags == 0) for soak simplicity; pass { stringTable: true } to the encoder when you want interning. Decoders reject unknown flag bits so old hosts fail loudly.
| Path | How |
|---|---|
Binary (default when host has binary_ops) |
Queue ops → encodeBatch → __react_commit_ops → decode_protos → same channel |
| Enum | __react_create_node, __react_append_child, … → ReactClient::send |
use bevy_react::react::proto::{decode_batch, encode_batch, encode_batch_with, BinaryOp, EncodeOptions};
let bytes = encode_batch("root", &[
BinaryOp::CreateNode {
node_id: 1,
node_type: "view".into(),
props_json: "{}".into(),
},
BinaryOp::Commit,
])?;
let (root, ops) = decode_batch(&bytes)?;
let interned = encode_batch_with("root", &ops, EncodeOptions { string_table: true })?;encode_protos / decode_protos convert to/from ReactClientProto.