Skip to main content

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}