Skip to main content

bevy_react/protocol/
op.rs

1//! JS→Bevy mutation ops: the [`Op`] batch the reconciler flushes per commit,
2//! plus [`OpBatch`]'s decode-warning attribution wrapper.
3
4use std::fmt;
5
6use serde::Deserialize;
7use serde::de::{self, Deserializer, Visitor};
8
9use crate::canvas::DrawCmd;
10
11use super::NodeId;
12use super::props::Props;
13
14/// A single mutation produced by the React reconciler during a commit. The
15/// reconciler batches a `Vec<Op>` per commit and flushes it across the boundary
16/// in one call.
17///
18/// The prop-bearing variants box their [`Props`] deliberately. An enum is as
19/// wide as its widest variant, and `Props` inlines four [`super::style::Style`]s
20/// (base + hover/press/focus) — several kilobytes. Unboxed, *every* element of
21/// the flushed `Vec<Op>` paid that width, so a batch of 5k `Remove`s moved tens
22/// of megabytes for ops carrying no props at all, and the decode/translate legs
23/// scaled with the widest variant instead of the actual payload. Boxing keeps
24/// `Op` pointer-sized (see `op_stays_narrow` below).
25#[derive(Debug, Clone, Deserialize)]
26#[serde(tag = "op", rename_all = "camelCase")]
27pub enum Op {
28    /// Tear down the entire current tree. Emitted first by every fresh runtime
29    /// so a hot reload clears the previous UI before the new render is applied.
30    Reset,
31    /// Spawn a host element (`node`, `button`, or `image`).
32    Create {
33        id: NodeId,
34        kind: String,
35        #[serde(default)]
36        props: Box<Props>,
37        /// Inline text content for a single-string `<text>`/`<textSpan>` (the
38        /// `shouldSetTextContent` fast path — no separate child text entity).
39        #[serde(default)]
40        text: Option<String>,
41    },
42    /// Spawn a standalone text node (a bare string outside any `<text>`).
43    CreateText { id: NodeId, text: String },
44    /// Spawn a text run inside a `<text>` element (a Bevy `TextSpan`). Its style
45    /// is inherited from the enclosing `<text>` at append time.
46    CreateTextSpan { id: NodeId, text: String },
47    /// Make `child` the last child of `parent` (`parent == ROOT_ID` is the root).
48    Append { parent: NodeId, child: NodeId },
49    /// Insert `child` before `before` under `parent`.
50    Insert {
51        parent: NodeId,
52        child: NodeId,
53        before: NodeId,
54    },
55    /// Detach and despawn `child` (and its descendants).
56    Remove { parent: NodeId, child: NodeId },
57    /// Apply a prop **delta** to an existing element, against its last applied
58    /// props (retained per node in `JsBridge::props_cache`).
59    ///
60    /// A field present in `props` is set; a wire name listed in `unset` is
61    /// reset to its default (for booleans: set `false`); a field in neither is
62    /// left unchanged. `props.style` is itself a field-level delta: its `Some`
63    /// fields overwrite the corresponding fields of the last applied style,
64    /// and style wire names listed in `style_unset` are cleared (`style_unset`
65    /// applies even when `props.style` is absent). The variant styles
66    /// (`hoverStyle`/`pressStyle`/`focusStyle`) and other object-valued props
67    /// are atomic: present replaces the whole value, `unset` clears it.
68    ///
69    /// The event-like props (`value`, `selectionStart`/`selectionEnd`,
70    /// `scrollTop`/`scrollLeft`, `draw`) keep their "present = act now" meaning
71    /// and are never part of the retained state (see [`Props::merge_delta`]).
72    Update {
73        id: NodeId,
74        #[serde(default)]
75        props: Box<Props>,
76        /// Top-level prop wire names (camelCase) reset to their defaults.
77        #[serde(default)]
78        unset: Vec<String>,
79        /// Style field wire names (camelCase) cleared from the merged style.
80        /// (The enum's `rename_all` covers variant names, not their fields, so
81        /// the wire name is spelled out.)
82        #[serde(default, rename = "styleUnset")]
83        style_unset: Vec<String>,
84    },
85    /// Replace the string of a text node.
86    UpdateText { id: NodeId, text: String },
87    /// Append draw commands to a `canvas` element's retained surface — the
88    /// imperative `getContext()` handle's microtask flush, or the JS
89    /// runtime's clear+replay of a declarative painter after a resize. Paint
90    /// accumulates on the retained pixels; a leading [`DrawCmd::Clear`] makes
91    /// the batch a replace. Bypasses the props cache entirely (nothing is
92    /// retained protocol-side). A missing or non-canvas node is skipped
93    /// silently, like every other op.
94    Draw { id: NodeId, cmds: Vec<DrawCmd> },
95}
96
97/// A `Vec<Op>` whose `Deserialize` brackets each element's decode with the
98/// [`crate::diag`] decode sink's watermarks, stamping every warning a field
99/// deserializer pushed with the op's target node id — the id is structurally
100/// out of scope down in the field visitors, but trivially known per op here.
101/// The wire format is exactly a plain op array; in release builds the
102/// bracketing calls are inline no-ops and this decodes like a bare `Vec<Op>`.
103pub struct OpBatch(pub Vec<Op>);
104
105/// The node an op targets, for decode-warning attribution. Tree ops carry no
106/// decodable values, so they have no meaningful target.
107fn op_target_id(op: &Op) -> Option<NodeId> {
108    match op {
109        Op::Create { id, .. }
110        | Op::CreateText { id, .. }
111        | Op::CreateTextSpan { id, .. }
112        | Op::Update { id, .. }
113        | Op::UpdateText { id, .. }
114        | Op::Draw { id, .. } => Some(*id),
115        Op::Reset | Op::Append { .. } | Op::Insert { .. } | Op::Remove { .. } => None,
116    }
117}
118
119impl<'de> Deserialize<'de> for OpBatch {
120    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
121        struct BatchVisitor;
122        impl<'de> Visitor<'de> for BatchVisitor {
123            type Value = Vec<Op>;
124            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
125                f.write_str("an array of reconciler ops")
126            }
127            fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<Op>, A::Error> {
128                // Clearing at batch start (not on drain) bounds the sink even
129                // when nothing ever drains it, and drops entries from a batch
130                // whose decode threw mid-way (Bevy never saw those ops).
131                crate::diag::decode_batch_start();
132                let mut ops = Vec::with_capacity(seq.size_hint().unwrap_or(0));
133                loop {
134                    let mark = crate::diag::decode_watermark();
135                    let Some(op) = seq.next_element::<Op>()? else {
136                        break;
137                    };
138                    crate::diag::decode_attribute_since(mark, op_target_id(&op));
139                    ops.push(op);
140                }
141                Ok(ops)
142            }
143        }
144        d.deserialize_seq(BatchVisitor).map(OpBatch)
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::protocol::style::Style;
152
153    /// Every element of a flushed batch is as wide as `Op`'s widest variant, so
154    /// a fat variant taxes ops that carry nothing (a `Remove` is four words of
155    /// payload). `Props` is kilobytes — it must stay behind a `Box`. This bound
156    /// is generous; it exists to fail loudly if a multi-kilobyte payload is ever
157    /// inlined into a variant again, not to pin an exact layout.
158    #[test]
159    fn op_stays_narrow() {
160        let size = std::mem::size_of::<Op>();
161        assert!(
162            size <= 128,
163            "Op grew to {size} bytes — box the payload of the variant that widened it \
164             (every op in a batch pays this width)"
165        );
166    }
167
168    /// `OpBatch` stamps decode-fallback warnings with the op that carried
169    /// them, so devtools can attribute "invalid length" to a node id even
170    /// though the field visitors can't see one. The decode sink is
171    /// thread-local (and cleared at batch start), so this is parallel-safe.
172    #[cfg(all(feature = "devtools", debug_assertions))]
173    #[test]
174    fn op_batch_attributes_decode_warnings() {
175        // A leftover from an earlier decode on this thread must not leak in.
176        crate::diag::decode_report("length", "stale", "stale entry");
177        let json = r#"[
178            {"op":"update","id":7,"props":{"style":{"width":"aa16"}}},
179            {"op":"append","parent":0,"child":7},
180            {"op":"update","id":9,"props":{"style":{"display":"flexx","padding":"1px bogus"}}}
181        ]"#;
182        let batch: OpBatch = serde_json::from_str(json).expect("batch decodes");
183        assert_eq!(batch.0.len(), 3, "fallbacks must not drop ops");
184        let warns = crate::diag::take_decode_warnings();
185        let brief: Vec<_> = warns
186            .iter()
187            .map(|w| (w.node, w.kind, w.value.as_str()))
188            .collect();
189        assert_eq!(
190            brief,
191            vec![
192                (Some(7), "length", "aa16"),
193                (Some(9), "display", "flexx"),
194                (Some(9), "rect", "bogus"),
195            ],
196        );
197        assert!(warns.iter().all(|w| !w.message.is_empty()));
198        assert!(
199            crate::diag::take_decode_warnings().is_empty(),
200            "drain empties the sink"
201        );
202    }
203
204    /// An `<editableText>` create op carries its controlled value and attributes.
205    #[test]
206    fn deserializes_editable_text_create() {
207        let json = r#"{"op":"create","id":7,"kind":"editableText","props":{
208            "value":"hi","maxLength":40,"multiline":true,"onChange":true,
209            "autofocus":true,"selectionStart":0,"selectionEnd":2,
210            "ariaLabel":"Name","onSelect":true,"onFocus":true,"onBlur":true,
211            "focusStyle":{"borderColor":"white"}}}"#;
212        match serde_json::from_str::<Op>(json).expect("valid op") {
213            Op::Create {
214                id, kind, props, ..
215            } => {
216                assert_eq!(id, 7);
217                assert_eq!(kind, "editableText");
218                assert_eq!(props.value.as_deref(), Some("hi"));
219                assert_eq!(props.max_length, Some(40));
220                assert!(props.multiline);
221                assert!(props.on_change);
222                assert!(props.autofocus);
223                assert_eq!(props.selection_start, Some(0));
224                assert_eq!(props.selection_end, Some(2));
225                assert_eq!(props.aria_label.as_deref(), Some("Name"));
226                assert!(props.on_select);
227                assert!(props.on_focus);
228                assert!(props.on_blur);
229                assert!(props.focus_style.is_some());
230            }
231            other => panic!("expected create, got {other:?}"),
232        }
233    }
234
235    /// An `update` op decodes with and without the unset lists — `styleUnset`
236    /// in particular must land in `style_unset` (the enum's `rename_all`
237    /// doesn't cover variant fields).
238    #[test]
239    fn deserializes_update_delta_form() {
240        let minimal: Op = serde_json::from_str(r#"{"op":"update","id":3,"props":{}}"#).unwrap();
241        match minimal {
242            Op::Update {
243                unset, style_unset, ..
244            } => {
245                assert!(unset.is_empty() && style_unset.is_empty());
246            }
247            other => panic!("expected update, got {other:?}"),
248        }
249        let full: Op = serde_json::from_str(
250            r#"{"op":"update","id":3,"props":{"style":{"width":1}},
251                "unset":["onClick"],"styleUnset":["backgroundColor"]}"#,
252        )
253        .unwrap();
254        match full {
255            Op::Update {
256                unset, style_unset, ..
257            } => {
258                assert_eq!(unset, vec!["onClick"]);
259                assert_eq!(style_unset, vec!["backgroundColor"]);
260            }
261            other => panic!("expected update, got {other:?}"),
262        }
263    }
264
265    /// A `draw` op decodes, including the clear commands (the imperative
266    /// canvas path). Struct-variant fields aren't renamed by the enum's
267    /// `rename_all`, so the wire form is pinned here.
268    #[test]
269    fn deserializes_draw_op() {
270        let op: Op = serde_json::from_str(
271            r##"{"op":"draw","id":7,"cmds":[
272                {"cmd":"clear"},
273                {"cmd":"clearRect","x":1.0,"y":2.0,"w":3.0,"h":4.0},
274                {"cmd":"fillStyle","color":"#f00"}
275            ]}"##,
276        )
277        .unwrap();
278        match op {
279            Op::Draw { id, cmds } => {
280                assert_eq!(id, 7);
281                assert_eq!(cmds.len(), 3);
282                assert_eq!(cmds[0], DrawCmd::Clear);
283                assert_eq!(
284                    cmds[1],
285                    DrawCmd::ClearRect {
286                        x: 1.0,
287                        y: 2.0,
288                        w: 3.0,
289                        h: 4.0
290                    }
291                );
292                assert_eq!(
293                    cmds[2],
294                    DrawCmd::FillStyle {
295                        color: "#f00".into()
296                    }
297                );
298            }
299            other => panic!("expected draw, got {other:?}"),
300        }
301    }
302
303    /// `cursor` decodes to the raw name (keyword or custom); resolution (registry
304    /// first, then system keyword) is deferred to `drive_cursor_icon`, like `fontFamily`.
305    #[test]
306    fn deserializes_cursor_name() {
307        let s: Style = serde_json::from_str(r#"{ "cursor": "pointer" }"#).expect("cursor decodes");
308        assert_eq!(s.cursor.as_deref(), Some("pointer"));
309
310        let s: Style =
311            serde_json::from_str(r#"{ "cursor": "hand" }"#).expect("custom name decodes");
312        assert_eq!(s.cursor.as_deref(), Some("hand"));
313    }
314}