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}