hypen_engine/reconcile/patch.rs
1use super::tree::ResolvedProps;
2use crate::ir::NodeId;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use slotmap::Key;
6
7/// Serde shim so `ResolvedProps` (`Arc<IndexMap<...>>`) serializes exactly
8/// as its inner map does, without pulling in serde's global `rc` feature.
9/// Wire format is indistinguishable from a bare `IndexMap<String, Value>`.
10mod resolved_props_serde {
11 use super::ResolvedProps;
12 use indexmap::IndexMap;
13 use std::sync::Arc;
14
15 pub fn serialize<S>(value: &ResolvedProps, serializer: S) -> Result<S::Ok, S::Error>
16 where
17 S: serde::Serializer,
18 {
19 serde::Serialize::serialize(&**value, serializer)
20 }
21
22 pub fn deserialize<'de, D>(deserializer: D) -> Result<ResolvedProps, D::Error>
23 where
24 D: serde::Deserializer<'de>,
25 {
26 use serde::Deserialize;
27 IndexMap::deserialize(deserializer).map(Arc::new)
28 }
29}
30
31/// Stable, compact serialization for NodeId.
32///
33/// Returns a decimal string derived directly from the slotmap key's FFI
34/// representation (`KeyData::as_ffi()`), which packs the slot's index and
35/// version into a `u64`. The result is deterministic per `NodeId` without
36/// any shared state — no mutex, no global map, no atomic counter.
37///
38/// # Stability
39///
40/// This is an internal implementation detail. External consumers should
41/// treat node ID strings in patches as opaque identifiers. The encoding
42/// is stable within a process run but may change between engine versions.
43#[doc(hidden)]
44#[inline]
45pub fn node_id_str(id: NodeId) -> String {
46 id.data().as_ffi().to_string()
47}
48
49/// Platform-agnostic patch operations for updating the UI.
50///
51/// Patches are the **wire protocol** between the Hypen engine and platform
52/// renderers (DOM, Canvas, iOS UIKit, Android Views). Every mutation to the
53/// UI tree is expressed as an ordered sequence of `Patch` values.
54///
55/// # Serialization
56///
57/// Patches serialize to JSON with a `"type"` discriminator and **camelCase**
58/// field names for direct JavaScript consumption:
59///
60/// ```json
61/// {"type": "create", "id": "1", "elementType": "Text", "props": {"0": "Hello"}}
62/// {"type": "insert", "parentId": "root", "id": "1", "beforeId": null}
63/// ```
64///
65/// # Node IDs
66///
67/// Node IDs are opaque string identifiers (currently compact integers like
68/// `"1"`, `"42"`). Renderers must treat them as opaque — the format may
69/// change between versions. The special parent ID `"root"` refers to the
70/// renderer's root container.
71///
72/// # Ordering
73///
74/// Within a single render cycle, patches are ordered such that:
75/// 1. `Create` always precedes `Insert` for the same node
76/// 2. `SetProp`/`SetText` follow `Create` for new nodes
77/// 3. `Remove` is always the last operation for a given node
78/// 4. `Insert`/`Move` specify position via `before_id` (`None` = append)
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(tag = "type", rename_all = "camelCase")]
81pub enum Patch {
82 /// Create a new element node with initial properties.
83 ///
84 /// The renderer should allocate a platform-native element and store it by
85 /// `id`. The node is not yet visible — a subsequent `Insert` attaches it.
86 ///
87 /// `props` is `Arc<IndexMap<...>>` so emitting a `Create` for an existing
88 /// `InstanceNode` is an Arc clone rather than a deep copy of the map.
89 /// Wire format is unchanged — the custom serde shim serializes the
90 /// inner map directly without exposing the Arc.
91 #[serde(rename_all = "camelCase")]
92 Create {
93 /// Opaque node identifier
94 id: String,
95 /// Element type name (e.g. `"Text"`, `"Column"`, `"Button"`)
96 element_type: String,
97 /// Initial properties. Key `"0"` is the positional text content.
98 #[serde(with = "resolved_props_serde")]
99 props: ResolvedProps,
100 },
101
102 /// Update a single property on an existing node.
103 #[serde(rename_all = "camelCase")]
104 SetProp {
105 id: String,
106 /// Property name (e.g. `"color"`, `"fontSize"`)
107 name: String,
108 /// New value
109 value: Value,
110 },
111
112 /// Remove a property from an existing node (revert to default).
113 #[serde(rename_all = "camelCase")]
114 RemoveProp {
115 id: String,
116 /// Property name to remove
117 name: String,
118 },
119
120 /// Set the text content of a node.
121 ///
122 /// **Reserved for future use — not currently emitted by the engine.**
123 /// The reconciler represents text changes as `SetProp { name: "0", ... }`
124 /// (the positional content slot), so no production code path constructs
125 /// a `SetText` patch today. Renderers must still handle this variant for
126 /// forward compatibility; removing it would break the wire format.
127 #[serde(rename_all = "camelCase")]
128 SetText {
129 id: String,
130 /// New text content
131 text: String,
132 },
133
134 /// Insert a node as a child of `parent_id`.
135 ///
136 /// If `before_id` is `Some`, insert before that sibling. If `None`, append.
137 #[serde(rename_all = "camelCase")]
138 Insert {
139 /// Parent node ID, or `"root"` for the root container
140 parent_id: String,
141 id: String,
142 /// Insert before this sibling, or `null` to append
143 before_id: Option<String>,
144 },
145
146 /// Move an already-inserted node to a new position within its parent.
147 #[serde(rename_all = "camelCase")]
148 Move {
149 parent_id: String,
150 id: String,
151 before_id: Option<String>,
152 },
153
154 /// Remove a node from the tree and deallocate it.
155 #[serde(rename_all = "camelCase")]
156 Remove { id: String },
157
158 /// Detach a subtree from its parent without tearing it down.
159 ///
160 /// The renderer must **unlink** `id` from its parent's children list
161 /// but keep the native element and its descendants alive (same
162 /// identifier, same props, same children). A subsequent `Attach`
163 /// can reinsert the subtree with zero rebuild work. If `Remove`
164 /// arrives instead, the subtree is torn down normally.
165 ///
166 /// Used by the Router reconciler to cache off-screen route
167 /// subtrees, so navigating back to a previously-visited route
168 /// skips both the engine's keyed-diff work and the renderer's
169 /// element-creation work.
170 #[serde(rename_all = "camelCase")]
171 Detach { id: String },
172
173 /// Reattach a previously-`Detach`ed subtree to a parent.
174 ///
175 /// The `id` must reference a still-alive native element that was
176 /// detached earlier in the session. If `before_id` is `Some`, the
177 /// subtree is inserted before that sibling; `None` appends.
178 #[serde(rename_all = "camelCase")]
179 Attach {
180 /// Parent node ID, or `"root"` for the root container
181 parent_id: String,
182 id: String,
183 /// Insert before this sibling, or `null` to append
184 before_id: Option<String>,
185 },
186}
187
188impl Patch {
189 /// Construct a `Create` patch. `props` must already be an
190 /// `Arc`-wrapped resolved-prop map; callers holding a bare
191 /// `IndexMap` wrap it explicitly via `Arc::new(...)`.
192 pub fn create(id: NodeId, element_type: String, props: ResolvedProps) -> Self {
193 Self::Create {
194 id: node_id_str(id),
195 element_type,
196 props,
197 }
198 }
199
200 pub fn set_prop(id: NodeId, name: String, value: Value) -> Self {
201 Self::SetProp {
202 id: node_id_str(id),
203 name,
204 value,
205 }
206 }
207
208 pub fn remove_prop(id: NodeId, name: String) -> Self {
209 Self::RemoveProp {
210 id: node_id_str(id),
211 name,
212 }
213 }
214
215 pub fn set_text(id: NodeId, text: String) -> Self {
216 Self::SetText {
217 id: node_id_str(id),
218 text,
219 }
220 }
221
222 pub fn insert(parent_id: NodeId, id: NodeId, before_id: Option<NodeId>) -> Self {
223 Self::Insert {
224 parent_id: node_id_str(parent_id),
225 id: node_id_str(id),
226 before_id: before_id.map(node_id_str),
227 }
228 }
229
230 /// Insert a root node into the "root" container
231 pub fn insert_root(id: NodeId) -> Self {
232 Self::Insert {
233 parent_id: "root".to_string(),
234 id: node_id_str(id),
235 before_id: None,
236 }
237 }
238
239 pub fn move_node(parent_id: NodeId, id: NodeId, before_id: Option<NodeId>) -> Self {
240 Self::Move {
241 parent_id: node_id_str(parent_id),
242 id: node_id_str(id),
243 before_id: before_id.map(node_id_str),
244 }
245 }
246
247 pub fn remove(id: NodeId) -> Self {
248 Self::Remove {
249 id: node_id_str(id),
250 }
251 }
252
253 /// Emit a `Detach` patch for the given node.
254 ///
255 /// Instructs the renderer to unlink the subtree rooted at `id`
256 /// from its parent without destroying the native element. A
257 /// subsequent `Attach` can reinsert it.
258 pub fn detach(id: NodeId) -> Self {
259 Self::Detach {
260 id: node_id_str(id),
261 }
262 }
263
264 /// Emit an `Attach` patch to reinsert a previously-detached node
265 /// as a child of `parent_id` (with optional `before_id` position).
266 pub fn attach(parent_id: NodeId, id: NodeId, before_id: Option<NodeId>) -> Self {
267 Self::Attach {
268 parent_id: node_id_str(parent_id),
269 id: node_id_str(id),
270 before_id: before_id.map(node_id_str),
271 }
272 }
273
274 /// Emit an `Attach` patch targeting the `"root"` container. Used when
275 /// a control-flow container (Router/Conditional) sitting at the IR
276 /// root caches and re-attaches its matched route's subtree — the
277 /// attach has to bypass the container's own (never-created) NodeId.
278 pub fn attach_root(id: NodeId, before_id: Option<NodeId>) -> Self {
279 Self::Attach {
280 parent_id: "root".to_string(),
281 id: node_id_str(id),
282 before_id: before_id.map(node_id_str),
283 }
284 }
285}