Skip to main content

tern_sdk/
reconcile.rs

1//! Layer 4: reconcile. A view diffed into frame ops with derived ids.
2//! Tern's plugin worker uses this reconciler directly.
3//!
4//! A region root's id is its region's name; a child's is `<parent>.<key>`,
5//! where `key` is its `key` prop (a string, or an integer in decimal), else
6//! its index among its siblings. Two siblings with one id reject the whole
7//! view. The same id and kind become one `set` of the changed props (removed
8//! ones `null`) and, for text kinds, a `text` op (`append` when the new text
9//! extends a non-empty old one); a kind change is `del` + `add`; children
10//! are deleted, then placed from the last back (added, re-added or moved
11//! before their next sibling), each kept child diffed right after it is
12//! placed.
13
14use std::collections::{HashMap, HashSet};
15
16use serde_json::{Map, Value};
17
18use crate::{
19	Error,
20	ui::{
21		Node, View,
22		node::{Handler, Trigger},
23	},
24	wire::{Event, Op, TextMode, WireNode, kind},
25};
26
27/// The regions of a surface, in the order they are added.
28pub const REGIONS: [&str; 3] = ["main", "dock", "layer"];
29
30/// One node of a view with its id assigned.
31#[derive(Clone, Debug, PartialEq)]
32pub struct Tree {
33	/// `parent.(key or index)`, or the region name at the top.
34	id:       String,
35	/// The kind.
36	kind:     String,
37	/// Props as the view gave them (with handler actions merged in).
38	props:    Map<String, Value>,
39	/// Children in order.
40	children: Vec<Self>,
41}
42
43impl Tree {
44	/// The node's id.
45	pub fn id(&self) -> &str {
46		&self.id
47	}
48
49	/// The node's kind.
50	pub fn kind(&self) -> &str {
51		&self.kind
52	}
53
54	/// The node's props.
55	pub const fn props(&self) -> &Map<String, Value> {
56		&self.props
57	}
58
59	/// The node's children.
60	pub fn children(&self) -> &[Self] {
61		&self.children
62	}
63
64	/// The node as an `add` op carries it, ids at every level.
65	pub fn wire(&self) -> WireNode {
66		WireNode {
67			id: self.id.clone(),
68			k:  self.kind.clone(),
69			p:  self.props.clone(),
70			c:  self.children.iter().map(Self::wire).collect(),
71		}
72	}
73
74	/// Builds the tree of `node` under `id`, collecting its handlers.
75	fn build<M>(id: String, node: Node<M>, routes: &mut Routes<M>) -> Result<Self, Error> {
76		let Node { kind, mut props, children: mut nodes, handlers, slots } = node;
77		// Slotted children come after the body, as in every SDK, so a body
78		// child's index (and the id derived from it) doesn't depend on its
79		// slots.
80		let slotted = |c: &Node<M>| {
81			c.props
82				.get("key")
83				.and_then(Value::as_str)
84				.is_some_and(|k| slots.contains(&k))
85		};
86		nodes.sort_by_key(slotted);
87		for slot in &slots {
88			props.insert((*slot).into(), Value::String(format!("{id}.{slot}")));
89		}
90		if !handlers.is_empty() {
91			let found = attach(&id, &mut props, handlers);
92			routes.by_id.insert(id.clone(), found);
93		}
94		let mut seen = HashSet::with_capacity(nodes.len());
95		let mut children = Vec::with_capacity(nodes.len());
96		for (i, child) in nodes.into_iter().enumerate() {
97			let key = match child.props.get("key") {
98				Some(Value::String(k)) => k.clone(),
99				Some(Value::Number(n)) => n.to_string(),
100				_ => i.to_string(),
101			};
102			let child_id = format!("{id}.{key}");
103			if !seen.insert(child_id.clone()) {
104				return Err(Error::DuplicateId { path: id, key });
105			}
106			children.push(Self::build(child_id, child, routes)?);
107		}
108		Ok(Self { id, kind, props, children })
109	}
110
111	/// Builds a wire node with the same validation used by plugin views.
112	fn from_json(id: String, node: &Value) -> Result<Self, Error> {
113		let Some(obj) = node.as_object() else {
114			return Err(Error::InvalidView(format!("{id}: a node must be a table, got {node}")));
115		};
116		let Some(kind) = obj.get("k").and_then(Value::as_str) else {
117			return Err(Error::InvalidView(format!("{id}: a node needs a string `k`")));
118		};
119		let props = match obj.get("p") {
120			None | Some(Value::Null) => Map::new(),
121			Some(Value::Object(p)) => p.clone(),
122			Some(other) => {
123				return Err(Error::InvalidView(format!("{id}: `p` must be a table, got {other}")));
124			},
125		};
126		let nodes: &[Value] = match obj.get("c") {
127			None | Some(Value::Null) => &[],
128			Some(Value::Array(c)) => c,
129			Some(Value::Object(c)) if c.is_empty() => &[],
130			Some(other) => {
131				return Err(Error::InvalidView(format!("{id}: `c` must be a list, got {other}")));
132			},
133		};
134		let mut seen = HashSet::with_capacity(nodes.len());
135		let mut children = Vec::with_capacity(nodes.len());
136		for (i, child) in nodes.iter().enumerate() {
137			let key = match child.get("p").and_then(|p| p.get("key")) {
138				Some(Value::String(k)) => k.clone(),
139				Some(Value::Number(n)) => n.to_string(),
140				_ => i.to_string(),
141			};
142			let child_id = format!("{id}.{key}");
143			if !seen.insert(child_id.clone()) {
144				return Err(Error::DuplicateId { path: id, key });
145			}
146			children.push(Self::from_json(child_id, child)?);
147		}
148		Ok(Self { id, kind: kind.to_owned(), props, children })
149	}
150
151	/// Whether the `text` prop is primary text, updated with the `text` op.
152	fn has_text(&self) -> bool {
153		kind::has_text(&self.kind)
154	}
155}
156
157/// What a handler is routed by.
158enum Route {
159	/// `action` events with this action (`act` or `act=value`).
160	Act(String),
161	/// Events of this `ev`.
162	Ev(&'static str),
163}
164
165/// The handlers of a reconciled view, by node id.
166pub(crate) struct Routes<M> {
167	/// Each node's handlers, in the order attached.
168	by_id: HashMap<String, Vec<(Route, Handler<M>)>>,
169}
170
171impl<M> Default for Routes<M> {
172	fn default() -> Self {
173		Self { by_id: HashMap::new() }
174	}
175}
176
177impl<M> Routes<M> {
178	/// The message of the first handler on node `id` that answers `event`.
179	fn at(&self, id: &str, event: &Event, matches: impl Fn(&Route) -> bool) -> Option<M> {
180		self
181			.by_id
182			.get(id)?
183			.iter()
184			.filter(|(r, _)| matches(r))
185			.find_map(|(_, h)| h(event))
186	}
187
188	/// Routes `event` to a handler of the node it names: for a list's
189	/// `select`/`activate`, the item's own handlers first, then the list's.
190	pub(crate) fn route(&self, event: &Event) -> Option<M> {
191		let id = event.id()?;
192		match event {
193			Event::Action(a) => self.at(id, event, |r| {
194				matches!(r, Route::Act(n) if *n == a.act || n.split_once('=')
195				.is_some_and(|(act, value)| {
196					act == a.act && a.value.as_ref().and_then(Value::as_str) == Some(value)
197				}))
198			}),
199			Event::Select(s) | Event::Activate(s) => {
200				let name = event.name();
201				let is = |r: &Route| matches!(r, Route::Ev(e) if *e == name);
202				(s.item != id)
203					.then(|| self.at(&s.item, event, is))
204					.flatten()
205					.or_else(|| self.at(id, event, is))
206			},
207			_ => {
208				let name = event.name();
209				self.at(id, event, |r| matches!(r, Route::Ev(e) if *e == name))
210			},
211		}
212	}
213}
214
215/// Merges the actions a node's handlers need into its props and returns
216/// their routes.
217fn attach<M>(
218	id: &str,
219	props: &mut Map<String, Value>,
220	handlers: Vec<(Trigger, Handler<M>)>,
221) -> Vec<(Route, Handler<M>)> {
222	let mut out = Vec::with_capacity(handlers.len());
223	for (trigger, handler) in handlers {
224		let route = match trigger {
225			Trigger::Action(act) => Route::Act(act),
226			Trigger::Event(ev) => Route::Ev(ev),
227			pointer => {
228				let actions = props
229					.entry("actions")
230					.or_insert_with(|| Value::Object(Map::new()));
231				let Value::Object(actions) = actions else {
232					tracing::warn!(id, "`actions` is not an object here; pointer handler ignored");
233					continue;
234				};
235				match pointer {
236					Trigger::Click | Trigger::Dblclick => {
237						let gesture = if pointer == Trigger::Click {
238							"click"
239						} else {
240							"dblclick"
241						};
242						let act = actions
243							.entry(gesture)
244							.or_insert_with(|| Value::String(gesture.into()))
245							.as_str()
246							.unwrap_or(gesture)
247							.to_owned();
248						Route::Act(act)
249					},
250					Trigger::Menu(act) => {
251						let menu = actions
252							.entry("menu")
253							.or_insert_with(|| Value::Array(Vec::new()));
254						if let Value::Array(menu) = menu
255							&& !menu
256								.iter()
257								.any(|m| m.as_str().or_else(|| m.get("id")?.as_str()) == Some(act.as_str()))
258						{
259							menu.push(Value::String(act.clone()));
260						}
261						Route::Act(act)
262					},
263					Trigger::Action(_) | Trigger::Event(_) => unreachable!("matched above"),
264				}
265			},
266		};
267		out.push((route, handler));
268	}
269	out
270}
271
272/// A view with ids assigned: the tree of each region, as last sent.
273#[derive(Clone, Debug, Default, PartialEq)]
274pub struct Doc {
275	/// `main`, `dock`, `layer`.
276	regions: [Option<Tree>; 3],
277}
278
279impl Doc {
280	/// The empty view.
281	pub fn new() -> Self {
282		Self::default()
283	}
284
285	/// Reconciles a view's tree (handlers are dropped).
286	///
287	/// # Errors
288	/// [`Error::DuplicateId`] when two siblings share an id.
289	pub fn from_view<M>(view: View<M>) -> Result<Self, Error> {
290		Self::build(view).map(|(doc, _)| doc)
291	}
292
293	/// Builds a view from its wire form `{main?, dock?, layer?}` of nodes
294	/// `{k, p?, c?}` (`null` is the empty view). Region roots must be nodes,
295	/// not lists; use [`View`] builders for implicit `col` wrapping.
296	///
297	/// Missing or null `p` and `c` mean no props or children. An empty
298	/// object `c` also means no children (an empty Luau table). Other `p`
299	/// values must be objects and other `c` values must be lists. Errors
300	/// include the node path; duplicate-key errors also name the key.
301	///
302	/// # Errors
303	/// [`Error::InvalidView`] for a malformed view, [`Error::DuplicateId`]
304	/// when two siblings share an id.
305	pub fn from_json(value: &Value) -> Result<Self, Error> {
306		let mut regions = [None, None, None];
307		match value {
308			Value::Null => {},
309			Value::Object(obj) => {
310				if let Some(extra) = obj.keys().find(|k| !REGIONS.contains(&k.as_str())) {
311					return Err(Error::InvalidView(format!(
312						"view: unknown region {extra:?} (main, dock or layer)"
313					)));
314				}
315				for (slot, name) in regions.iter_mut().zip(REGIONS) {
316					if let Some(node) = obj.get(name).filter(|n| !n.is_null()) {
317						*slot = Some(Tree::from_json(name.to_owned(), node)?);
318					}
319				}
320			},
321			other => {
322				return Err(Error::InvalidView(format!(
323					"view: expected {{main?, dock?, layer?}}, got {other}"
324				)));
325			},
326		}
327		Ok(Self { regions })
328	}
329
330	/// Reconciles a view, collecting the routes of its handlers.
331	pub(crate) fn build<M>(view: View<M>) -> Result<(Self, Routes<M>), Error> {
332		let mut routes = Routes::default();
333		let mut regions = [None, None, None];
334		let View { main, dock, layer } = view;
335		for ((slot, name), node) in regions.iter_mut().zip(REGIONS).zip([main, dock, layer]) {
336			if let Some(node) = node {
337				*slot = Some(Tree::build(name.to_owned(), node, &mut routes)?);
338			}
339		}
340		Ok((Self { regions }, routes))
341	}
342
343	/// Drops transient regions removed when a surface closes.
344	pub(crate) fn retain_main(&mut self) {
345		self.regions[1] = None;
346		self.regions[2] = None;
347	}
348
349	/// The region trees in order `main`, `dock`, `layer`.
350	pub const fn regions(&self) -> &[Option<Tree>; 3] {
351		&self.regions
352	}
353
354	/// The ops turning this view into `next` on surface `surface`.
355	pub fn ops(&self, next: &Self, surface: &str) -> Vec<Op> {
356		let mut ops = Vec::new();
357		for (old, new) in self.regions.iter().zip(&next.regions) {
358			match (old, new) {
359				(None, None) => {},
360				(Some(old), None) => ops.push(Op::Del { id: old.id.clone() }),
361				(None, Some(new)) => ops.push(add(new, surface, None)),
362				(Some(old), Some(new)) if old.kind != new.kind => {
363					ops.push(Op::Del { id: old.id.clone() });
364					ops.push(add(new, surface, None));
365				},
366				(Some(old), Some(new)) => diff(old, new, &mut ops),
367			}
368		}
369		ops
370	}
371}
372
373/// The `add` op of `tree` under `parent`, before `before`.
374fn add(tree: &Tree, parent: &str, before: Option<&str>) -> Op {
375	Op::Add { parent: parent.to_owned(), before: before.map(str::to_owned), node: tree.wire() }
376}
377
378/// Ops turning `old` into `new`, which share id and kind.
379fn diff(old: &Tree, new: &Tree, ops: &mut Vec<Op>) {
380	let text = new.has_text();
381	let mut set = Map::new();
382	for (key, value) in &new.props {
383		if text && key == "text" {
384			continue;
385		}
386		if old.props.get(key) != Some(value) {
387			set.insert(key.clone(), value.clone());
388		}
389	}
390	for key in old.props.keys() {
391		let primary = text && key == "text";
392		if !primary && !new.props.contains_key(key) {
393			set.insert(key.clone(), Value::Null);
394		}
395	}
396	if !set.is_empty() {
397		ops.push(Op::Set { id: new.id.clone(), props: set });
398	}
399	if text {
400		let before = old.props.get("text");
401		let after = new.props.get("text");
402		if before != after {
403			match (before.and_then(Value::as_str), after.and_then(Value::as_str)) {
404				(Some(a), Some(b)) if !a.is_empty() && b.starts_with(a) => ops.push(Op::Text {
405					id:   new.id.clone(),
406					mode: TextMode::Append,
407					text: b[a.len()..].to_owned(),
408				}),
409				(_, Some(b)) => ops.push(Op::Text {
410					id:   new.id.clone(),
411					mode: TextMode::Replace,
412					text: b.to_owned(),
413				}),
414				(_, None) => {
415					let mut props = Map::new();
416					props.insert("text".into(), Value::Null);
417					ops.push(Op::Set { id: new.id.clone(), props });
418				},
419			}
420		}
421	}
422	children(old, new, ops);
423}
424
425/// Ops turning `old`'s children into `new`'s: deletions first, then from
426/// the last child back each new, re-kinded or out-of-order child is placed
427/// before its (already placed) next sibling, and kept children diff.
428fn children(old: &Tree, new: &Tree, ops: &mut Vec<Op>) {
429	let before: HashMap<&str, (usize, &Tree)> = old
430		.children
431		.iter()
432		.enumerate()
433		.map(|(i, c)| (c.id.as_str(), (i, c)))
434		.collect();
435	let kept: HashSet<&str> = new.children.iter().map(|c| c.id.as_str()).collect();
436	for child in &old.children {
437		if !kept.contains(child.id.as_str()) {
438			ops.push(Op::Del { id: child.id.clone() });
439		}
440	}
441	// Children that keep id and kind; those on the longest run of rising old
442	// positions stay put, the rest move.
443	let same: Vec<(usize, usize)> = new
444		.children
445		.iter()
446		.enumerate()
447		.filter_map(|(at, c)| {
448			before
449				.get(c.id.as_str())
450				.filter(|(_, o)| o.kind == c.kind)
451				.map(|(i, _)| (at, *i))
452		})
453		.collect();
454	let stay: HashSet<usize> = rising(&same.iter().map(|(_, i)| *i).collect::<Vec<_>>())
455		.into_iter()
456		.map(|k| same[k].0)
457		.collect();
458	let mut next: Option<&str> = None;
459	for (at, child) in new.children.iter().enumerate().rev() {
460		match before.get(child.id.as_str()) {
461			None => ops.push(add(child, &new.id, next)),
462			Some((_, o)) if o.kind != child.kind => {
463				ops.push(Op::Del { id: child.id.clone() });
464				ops.push(add(child, &new.id, next));
465			},
466			Some((_, o)) => {
467				if !stay.contains(&at) {
468					ops.push(Op::Move {
469						id:     child.id.clone(),
470						parent: new.id.clone(),
471						before: next.map(str::to_owned),
472					});
473				}
474				diff(o, child, ops);
475			},
476		}
477		next = Some(child.id.as_str());
478	}
479}
480
481/// Indices into `seq` of one longest strictly rising subsequence.
482fn rising(seq: &[usize]) -> Vec<usize> {
483	// tails[k]: index of the smallest tail of a rising run of length k + 1.
484	let mut tails: Vec<usize> = Vec::new();
485	let mut prev: Vec<Option<usize>> = vec![None; seq.len()];
486	for (i, &v) in seq.iter().enumerate() {
487		let at = tails.partition_point(|&t| seq[t] < v);
488		prev[i] = at.checked_sub(1).map(|p| tails[p]);
489		if at == tails.len() {
490			tails.push(i);
491		} else {
492			tails[at] = i;
493		}
494	}
495	let mut out = Vec::with_capacity(tails.len());
496	let mut cur = tails.last().copied();
497	while let Some(i) = cur {
498		out.push(i);
499		cur = prev[i];
500	}
501	out.reverse();
502	out
503}
504
505#[cfg(test)]
506mod tests {
507	use serde_json::json;
508
509	use super::*;
510	use crate::ui;
511
512	#[test]
513	fn json_empty_luau_tables_are_empty_children() {
514		let bare = Doc::from_json(&json!({"main": {"k": "col"}})).unwrap();
515		for children in [Value::Null, json!({}), json!([])] {
516			let doc = Doc::from_json(&json!({
517				"main": {"k": "col", "p": null, "c": children},
518				"dock": null, "layer": null,
519			}))
520			.unwrap();
521			assert_eq!(doc, bare);
522			assert!(doc.ops(&bare, "p").is_empty());
523		}
524		let nested = Doc::from_json(&json!({
525			"main": {"k": "col", "c": [{"k": "col", "p": {}, "c": {}}]},
526		}))
527		.unwrap();
528		assert!(
529			nested.regions()[0].as_ref().unwrap().children()[0]
530				.children()
531				.is_empty()
532		);
533		assert_eq!(Doc::from_json(&Value::Null).unwrap(), Doc::new());
534	}
535
536	#[test]
537	fn json_rejects_unknown_regions_even_when_null() {
538		for node in [Value::Null, json!({"k": "col"})] {
539			let error = Doc::from_json(&json!({"side": node})).unwrap_err();
540			assert_eq!(error.to_string(), "view: unknown region \"side\" (main, dock or layer)");
541		}
542		for value in [json!(1), json!([]), json!("main")] {
543			assert!(matches!(Doc::from_json(&value), Err(Error::InvalidView(_))));
544		}
545	}
546
547	#[test]
548	fn json_rejects_malformed_nodes_with_paths() {
549		for (node, message) in [
550			(json!(1), "a node must be a table, got 1"),
551			(json!([]), "a node must be a table, got []"),
552			(json!({}), "a node needs a string `k`"),
553			(json!({"k": false}), "a node needs a string `k`"),
554			(json!({"k": "col", "p": []}), "`p` must be a table, got []"),
555			(json!({"k": "col", "p": 1}), "`p` must be a table, got 1"),
556			(json!({"k": "col", "c": {"bad": 1}}), "`c` must be a list, got {\"bad\":1}"),
557			(json!({"k": "col", "c": false}), "`c` must be a list, got false"),
558			(json!({"k": "col", "c": "bad"}), "`c` must be a list, got \"bad\""),
559		] {
560			let error = Doc::from_json(&json!({"main": node})).unwrap_err();
561			assert_eq!(error.to_string(), format!("main: {message}"));
562			let error = Doc::from_json(&json!({
563				"main": {"k": "col", "c": [{"k": "col", "p": {"key": "nested"}, "c": [node]}]},
564			}))
565			.unwrap_err();
566			assert_eq!(error.to_string(), format!("main.nested.0: {message}"));
567		}
568	}
569
570	#[test]
571	fn slotted_nodes_become_keyed_children_sent_by_id() {
572		let view: View = View::new().main([crate::ui::card()
573			.head(crate::ui::text("Jobs"))
574			.end(crate::ui::button("Refresh"))
575			.end(crate::ui::button("Reload"))
576			.child(crate::ui::text("body"))
577			.key("panel")]);
578		let doc = Doc::from_view(view).unwrap();
579		let card = &doc.regions()[0].as_ref().unwrap().children()[0];
580		assert_eq!(card.props()["head"], json!("main.panel.head"));
581		assert_eq!(card.props()["end"], json!("main.panel.end"));
582		let ids: Vec<&str> = card.children().iter().map(Tree::id).collect();
583		assert_eq!(ids, ["main.panel.0", "main.panel.head", "main.panel.end"]);
584		assert_eq!(card.children()[2].props()["text"], json!("Reload"));
585	}
586
587	#[test]
588	fn text_after_a_slotted_head_drops_the_child() {
589		let card = crate::ui::card::<()>()
590			.head(crate::ui::text("x"))
591			.head("Plain");
592		let doc = Doc::from_view(View::new().main([card])).unwrap();
593		let card = &doc.regions()[0].as_ref().unwrap().children()[0];
594		assert_eq!(card.props()["head"], json!("Plain"));
595		assert!(card.children().is_empty());
596	}
597
598	#[test]
599	fn duplicate_child_errors_name_parent_and_key() {
600		for (keys, path, key) in [
601			([json!("a"), json!("a")], "main", "a"),
602			([json!(7), json!("7")], "main", "7"),
603			([json!("a.b"), json!("a.b")], "main", "a.b"),
604		] {
605			let children = keys.map(|key| json!({"k": "text", "p": {"key": key}}));
606			let node = json!({"k": "col", "c": children});
607			let error = Doc::from_json(&json!({"main": node})).unwrap_err();
608			assert_eq!(error.to_string(), format!("{path}: duplicate child key {key:?}"));
609			assert!(matches!(error, Error::DuplicateId { path: p, key: k } if p == path && k == key));
610			let error = Doc::from_json(&json!({
611				"main": {"k": "col", "c": [node]},
612			}))
613			.unwrap_err();
614			assert_eq!(error.to_string(), format!("main.0: duplicate child key {key:?}"));
615		}
616		let view = View::<()>::new().main([ui::text("a").key("x"), ui::text("b").key("x")]);
617		assert_eq!(Doc::from_view(view).unwrap_err().to_string(), "main: duplicate child key \"x\"");
618	}
619
620	#[test]
621	fn integer_keys_derive_decimal_ids() {
622		let doc = Doc::from_json(&json!({
623			"main": {"k": "col", "c": [
624				{"k": "text", "p": {"key": -42}},
625				{"k": "text", "p": {"key": u64::MAX}},
626			]},
627		}))
628		.unwrap();
629		let children = doc.regions()[0].as_ref().unwrap().children();
630		assert_eq!(children[0].id(), "main.-42");
631		assert_eq!(children[1].id(), "main.18446744073709551615");
632	}
633
634	#[test]
635	fn pointer_actions_match_both_name_and_value() {
636		let (doc, routes) = Doc::build(View::from(
637			ui::text("open")
638				.prop("actions", json!({"click": "open=one=two", "dblclick": "open=other"}))
639				.on_click(1)
640				.on_dblclick(2)
641				.on_action("fallback", 3),
642		))
643		.unwrap();
644		assert_eq!(
645			doc.regions()[0].as_ref().unwrap().children()[0].props()["actions"],
646			json!({"click": "open=one=two", "dblclick": "open=other"})
647		);
648		for (act, value, expected) in [
649			("open", Some("one=two"), Some(1)),
650			("open", Some("other"), Some(2)),
651			("open", Some("wrong"), None),
652			("open", None, None),
653			("wrong", Some("one=two"), None),
654			("fallback", Some("anything"), Some(3)),
655		] {
656			let mut raw = json!({"ev": "action", "sf": "s1", "id": "main.0", "act": act});
657			if let Some(value) = value {
658				raw["value"] = json!(value);
659			}
660			let event = Event::decode(raw.as_object().unwrap().clone());
661			assert_eq!(routes.route(&event), expected);
662		}
663	}
664
665	#[test]
666	fn retaining_main_drops_only_transient_regions() {
667		let mut doc = Doc::from_view(
668			View::<()>::new()
669				.main([ui::text("kept")])
670				.dock([ui::text("draft")])
671				.layer([ui::text("dialog")]),
672		)
673		.unwrap();
674		let main = doc.regions()[0].clone();
675		doc.retain_main();
676		assert_eq!(doc.regions(), &[main, None, None]);
677		let next = Doc::from_view(View::<()>::new().main([ui::text("kept")])).unwrap();
678		assert!(doc.ops(&next, "s1").is_empty());
679	}
680}