use std::fmt;
use serde::Deserialize;
use serde::de::{self, Deserializer, MapAccess, Visitor};
use super::NodeId;
use super::props::Props;
#[derive(Debug, Clone)]
pub enum Op {
Reset,
Create {
id: NodeId,
kind: String,
props: Box<Props>,
text: Option<String>,
},
CreateText { id: NodeId, text: String },
CreateTextSpan { id: NodeId, text: String },
Append { parent: NodeId, child: NodeId },
Insert {
parent: NodeId,
child: NodeId,
before: NodeId,
},
Remove { parent: NodeId, child: NodeId },
Update {
id: NodeId,
props: Box<Props>,
unset: Vec<String>,
style_unset: Vec<String>,
},
UpdateText { id: NodeId, text: String },
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
enum OpTag {
Reset,
Create,
CreateText,
CreateTextSpan,
Append,
Insert,
Remove,
Update,
UpdateText,
}
#[derive(Deserialize)]
#[serde(field_identifier, rename_all = "camelCase")]
enum OpKey {
Op,
Id,
Kind,
Props,
Text,
Parent,
Child,
Before,
Unset,
StyleUnset,
#[serde(other)]
Other,
}
impl<'de> Deserialize<'de> for Op {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_map(OpVisitor)
}
}
struct OpVisitor;
fn take<T, E: de::Error>(slot: &mut Option<T>, value: T, key: &'static str) -> Result<(), E> {
if slot.is_some() {
return Err(E::duplicate_field(key));
}
*slot = Some(value);
Ok(())
}
fn required<T, E: de::Error>(slot: Option<T>, key: &'static str) -> Result<T, E> {
slot.ok_or_else(|| E::missing_field(key))
}
impl<'de> Visitor<'de> for OpVisitor {
type Value = Op;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a reconciler op object tagged by `op`")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Op, A::Error> {
let mut tag: Option<OpTag> = None;
let mut id: Option<NodeId> = None;
let mut kind: Option<String> = None;
let mut props: Option<Box<Props>> = None;
let mut text: Option<String> = None;
let mut parent: Option<NodeId> = None;
let mut child: Option<NodeId> = None;
let mut before: Option<NodeId> = None;
let mut unset: Option<Vec<String>> = None;
let mut style_unset: Option<Vec<String>> = None;
let mut _scope: Option<crate::element::DecodeScope> = None;
while let Some(key) = map.next_key::<OpKey>()? {
match key {
OpKey::Op => take(&mut tag, map.next_value()?, "op")?,
OpKey::Id => take(&mut id, map.next_value()?, "id")?,
OpKey::Kind => {
let k: String = map.next_value()?;
_scope = Some(crate::element::DecodeScope::new(&k));
take(&mut kind, k, "kind")?;
}
OpKey::Props => take(&mut props, map.next_value()?, "props")?,
OpKey::Text => {
if let Some(t) = map.next_value::<Option<String>>()? {
take(&mut text, t, "text")?;
}
}
OpKey::Parent => take(&mut parent, map.next_value()?, "parent")?,
OpKey::Child => take(&mut child, map.next_value()?, "child")?,
OpKey::Before => take(&mut before, map.next_value()?, "before")?,
OpKey::Unset => take(&mut unset, map.next_value()?, "unset")?,
OpKey::StyleUnset => take(&mut style_unset, map.next_value()?, "styleUnset")?,
OpKey::Other => {
map.next_value::<de::IgnoredAny>()?;
}
}
}
Ok(match required(tag, "op")? {
OpTag::Reset => Op::Reset,
OpTag::Create => Op::Create {
id: required(id, "id")?,
kind: required(kind, "kind")?,
props: props.unwrap_or_default(),
text,
},
OpTag::CreateText => Op::CreateText {
id: required(id, "id")?,
text: required(text, "text")?,
},
OpTag::CreateTextSpan => Op::CreateTextSpan {
id: required(id, "id")?,
text: required(text, "text")?,
},
OpTag::Append => Op::Append {
parent: required(parent, "parent")?,
child: required(child, "child")?,
},
OpTag::Insert => Op::Insert {
parent: required(parent, "parent")?,
child: required(child, "child")?,
before: required(before, "before")?,
},
OpTag::Remove => Op::Remove {
parent: required(parent, "parent")?,
child: required(child, "child")?,
},
OpTag::Update => Op::Update {
id: required(id, "id")?,
props: props.unwrap_or_default(),
unset: unset.unwrap_or_default(),
style_unset: style_unset.unwrap_or_default(),
},
OpTag::UpdateText => Op::UpdateText {
id: required(id, "id")?,
text: required(text, "text")?,
},
})
}
}
pub struct OpBatch(pub Vec<Op>);
fn op_target_id(op: &Op) -> Option<NodeId> {
match op {
Op::Create { id, .. }
| Op::CreateText { id, .. }
| Op::CreateTextSpan { id, .. }
| Op::Update { id, .. }
| Op::UpdateText { id, .. } => Some(*id),
Op::Reset | Op::Append { .. } | Op::Insert { .. } | Op::Remove { .. } => None,
}
}
impl<'de> Deserialize<'de> for OpBatch {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct BatchVisitor;
impl<'de> Visitor<'de> for BatchVisitor {
type Value = Vec<Op>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("an array of reconciler ops")
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<Op>, A::Error> {
crate::diag::decode_batch_start();
let mut ops = Vec::with_capacity(seq.size_hint().unwrap_or(0));
loop {
let mark = crate::diag::decode_watermark();
let Some(op) = seq.next_element::<Op>()? else {
break;
};
crate::diag::decode_attribute_since(mark, op_target_id(&op));
ops.push(op);
}
Ok(ops)
}
}
d.deserialize_seq(BatchVisitor).map(OpBatch)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Style;
use crate::style::props::CURSOR;
#[test]
fn op_stays_narrow() {
let size = std::mem::size_of::<Op>();
assert!(
size <= 128,
"Op grew to {size} bytes — box the payload of the variant that widened it \
(every op in a batch pays this width)"
);
}
#[cfg(all(feature = "devtools", debug_assertions))]
#[test]
fn op_batch_attributes_decode_warnings() {
crate::diag::decode_report("length", "stale", "stale entry");
let json = r#"[
{"op":"update","id":7,"kind":"node","props":{"style":{"width":"aa16"}}},
{"op":"append","parent":0,"child":7},
{"op":"update","id":9,"kind":"node","props":{"style":{"display":"flexx","padding":"1px bogus"}}}
]"#;
let batch: OpBatch = serde_json::from_str(json).expect("batch decodes");
assert_eq!(batch.0.len(), 3, "fallbacks must not drop ops");
let warns = crate::diag::take_decode_warnings();
let brief: Vec<_> = warns
.iter()
.map(|w| (w.node, w.kind, w.value.as_str()))
.collect();
assert_eq!(
brief,
vec![
(Some(7), "length", "aa16"),
(Some(9), "display", "flexx"),
(Some(9), "rect", "bogus"),
],
);
assert!(warns.iter().all(|w| !w.message.is_empty()));
assert!(
crate::diag::take_decode_warnings().is_empty(),
"drain empties the sink"
);
}
#[test]
fn deserializes_editable_text_create() {
use crate::elements::editable::{
ARIA_LABEL, AUTOFOCUS, MAX_LENGTH, MULTILINE, SELECTION_END, SELECTION_START, VALUE,
};
crate::ext::install_builtin_registry();
let json = r#"{"op":"create","id":7,"kind":"editableText","props":{
"value":"hi","maxLength":40,"multiline":true,"onChange":true,
"autofocus":true,"selectionStart":0,"selectionEnd":2,
"ariaLabel":"Name","onSelect":true,"onFocus":true,"onBlur":true,
"focusStyle":{"borderColor":"white"}}}"#;
match serde_json::from_str::<Op>(json).expect("valid op") {
Op::Create {
id, kind, props, ..
} => {
assert_eq!(id, 7);
assert_eq!(kind, "editableText");
let a = &props.attrs;
assert_eq!(a.get(&VALUE).map(String::as_str), Some("hi"));
assert_eq!(a.get(&MAX_LENGTH), Some(&40));
assert_eq!(a.get(&MULTILINE), Some(&true));
assert_eq!(a.get(&AUTOFOCUS), Some(&true));
assert_eq!(a.get(&SELECTION_START), Some(&0));
assert_eq!(a.get(&SELECTION_END), Some(&2));
assert_eq!(a.get(&ARIA_LABEL).map(String::as_str), Some("Name"));
assert_eq!(props.handlers, 0b1111);
assert!(props.focus_style.is_some());
}
other => panic!("expected create, got {other:?}"),
}
}
#[test]
fn deserializes_update_delta_form() {
crate::ext::install_builtin_registry();
let minimal: Op =
serde_json::from_str(r#"{"op":"update","id":3,"kind":"node","props":{}}"#).unwrap();
match minimal {
Op::Update {
unset, style_unset, ..
} => {
assert!(unset.is_empty() && style_unset.is_empty());
}
other => panic!("expected update, got {other:?}"),
}
let full: Op = serde_json::from_str(
r#"{"op":"update","id":3,"kind":"node","props":{"style":{"width":1}},
"unset":["onClick"],"styleUnset":["backgroundColor"]}"#,
)
.unwrap();
match full {
Op::Update {
unset, style_unset, ..
} => {
assert_eq!(unset, vec!["onClick"]);
assert_eq!(style_unset, vec!["backgroundColor"]);
}
other => panic!("expected update, got {other:?}"),
}
}
#[test]
fn update_op_decodes_attributes_by_kind() {
use crate::elements::editable::VALUE;
crate::ext::install_builtin_registry();
let op: Op = serde_json::from_str(
r#"{"op":"update","id":7,"kind":"editableText","props":{"value":"hi"}}"#,
)
.unwrap();
match op {
Op::Update { id, props, .. } => {
assert_eq!(id, 7);
assert_eq!(
props.attrs.get(&VALUE).map(String::as_str),
Some("hi"),
"decoded by kind"
);
}
other => panic!("expected update, got {other:?}"),
}
assert!(
crate::element::decode_element().is_none(),
"the op's decode scope closes with it"
);
}
#[test]
fn deserializes_cursor_name() {
let s: Style = serde_json::from_str(r#"{ "cursor": "pointer" }"#).expect("cursor decodes");
assert_eq!(s.get(&CURSOR).map(String::as_str), Some("pointer"));
let s: Style =
serde_json::from_str(r#"{ "cursor": "hand" }"#).expect("custom name decodes");
assert_eq!(s.get(&CURSOR).map(String::as_str), Some("hand"));
}
}