Skip to main content

hypen_engine/reconcile/
patch.rs

1use crate::ir::NodeId;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::HashMap;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::Mutex;
7
8/// Monotonic counter for compact, stable node-ID serialization.
9static NEXT_ID: AtomicU64 = AtomicU64::new(1);
10
11/// Maps SlotMap `NodeId` keys to compact integer strings.
12/// Once a NodeId is assigned a string, it keeps it for the lifetime of the
13/// process.  This avoids depending on slotmap's `Debug` output format.
14static NODE_ID_MAP: Mutex<Option<HashMap<NodeId, String>>> = Mutex::new(None);
15
16/// Stable, compact serialization for NodeId.
17///
18/// Returns a short numeric string (e.g. `"1"`, `"42"`) instead of the
19/// previous `"NodeId(0v1)"` Debug format.  The mapping is deterministic
20/// per NodeId for the lifetime of the process.
21///
22/// # Stability
23///
24/// This is an internal implementation detail. External consumers should
25/// treat node ID strings in patches as opaque identifiers.
26#[doc(hidden)]
27#[inline]
28pub fn node_id_str(id: NodeId) -> String {
29    let mut guard = NODE_ID_MAP.lock().unwrap();
30    let map = guard.get_or_insert_with(HashMap::new);
31    map.entry(id)
32        .or_insert_with(|| NEXT_ID.fetch_add(1, Ordering::Relaxed).to_string())
33        .clone()
34}
35
36/// Platform-agnostic patch operations for updating the UI.
37///
38/// Patches are the **wire protocol** between the Hypen engine and platform
39/// renderers (DOM, Canvas, iOS UIKit, Android Views). Every mutation to the
40/// UI tree is expressed as an ordered sequence of `Patch` values.
41///
42/// # Serialization
43///
44/// Patches serialize to JSON with a `"type"` discriminator and **camelCase**
45/// field names for direct JavaScript consumption:
46///
47/// ```json
48/// {"type": "create", "id": "1", "elementType": "Text", "props": {"0": "Hello"}}
49/// {"type": "insert", "parentId": "root", "id": "1", "beforeId": null}
50/// ```
51///
52/// # Node IDs
53///
54/// Node IDs are opaque string identifiers (currently compact integers like
55/// `"1"`, `"42"`). Renderers must treat them as opaque — the format may
56/// change between versions. The special parent ID `"root"` refers to the
57/// renderer's root container.
58///
59/// # Ordering
60///
61/// Within a single render cycle, patches are ordered such that:
62/// 1. `Create` always precedes `Insert` for the same node
63/// 2. `SetProp`/`SetText` follow `Create` for new nodes
64/// 3. `Remove` is always the last operation for a given node
65/// 4. `Insert`/`Move` specify position via `before_id` (`None` = append)
66#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(tag = "type", rename_all = "camelCase")]
68pub enum Patch {
69    /// Create a new element node with initial properties.
70    ///
71    /// The renderer should allocate a platform-native element and store it by
72    /// `id`. The node is not yet visible — a subsequent `Insert` attaches it.
73    #[serde(rename_all = "camelCase")]
74    Create {
75        /// Opaque node identifier
76        id: String,
77        /// Element type name (e.g. `"Text"`, `"Column"`, `"Button"`)
78        element_type: String,
79        /// Initial properties. Key `"0"` is the positional text content.
80        props: indexmap::IndexMap<String, Value>,
81    },
82
83    /// Update a single property on an existing node.
84    #[serde(rename_all = "camelCase")]
85    SetProp {
86        id: String,
87        /// Property name (e.g. `"color"`, `"fontSize"`)
88        name: String,
89        /// New value
90        value: Value,
91    },
92
93    /// Remove a property from an existing node (revert to default).
94    #[serde(rename_all = "camelCase")]
95    RemoveProp {
96        id: String,
97        /// Property name to remove
98        name: String,
99    },
100
101    /// Set the text content of a node.
102    ///
103    /// **Reserved for future use — not currently emitted by the engine.**
104    /// The reconciler represents text changes as `SetProp { name: "0", ... }`
105    /// (the positional content slot), so no production code path constructs
106    /// a `SetText` patch today. Renderers must still handle this variant for
107    /// forward compatibility; removing it would break the wire format.
108    #[serde(rename_all = "camelCase")]
109    SetText {
110        id: String,
111        /// New text content
112        text: String,
113    },
114
115    /// Insert a node as a child of `parent_id`.
116    ///
117    /// If `before_id` is `Some`, insert before that sibling. If `None`, append.
118    #[serde(rename_all = "camelCase")]
119    Insert {
120        /// Parent node ID, or `"root"` for the root container
121        parent_id: String,
122        id: String,
123        /// Insert before this sibling, or `null` to append
124        before_id: Option<String>,
125    },
126
127    /// Move an already-inserted node to a new position within its parent.
128    #[serde(rename_all = "camelCase")]
129    Move {
130        parent_id: String,
131        id: String,
132        before_id: Option<String>,
133    },
134
135    /// Remove a node from the tree and deallocate it.
136    #[serde(rename_all = "camelCase")]
137    Remove { id: String },
138}
139
140impl Patch {
141    pub fn create(
142        id: NodeId,
143        element_type: String,
144        props: indexmap::IndexMap<String, Value>,
145    ) -> Self {
146        Self::Create {
147            id: node_id_str(id),
148            element_type,
149            props,
150        }
151    }
152
153    pub fn set_prop(id: NodeId, name: String, value: Value) -> Self {
154        Self::SetProp {
155            id: node_id_str(id),
156            name,
157            value,
158        }
159    }
160
161    pub fn remove_prop(id: NodeId, name: String) -> Self {
162        Self::RemoveProp {
163            id: node_id_str(id),
164            name,
165        }
166    }
167
168    pub fn set_text(id: NodeId, text: String) -> Self {
169        Self::SetText {
170            id: node_id_str(id),
171            text,
172        }
173    }
174
175    pub fn insert(parent_id: NodeId, id: NodeId, before_id: Option<NodeId>) -> Self {
176        Self::Insert {
177            parent_id: node_id_str(parent_id),
178            id: node_id_str(id),
179            before_id: before_id.map(node_id_str),
180        }
181    }
182
183    /// Insert a root node into the "root" container
184    pub fn insert_root(id: NodeId) -> Self {
185        Self::Insert {
186            parent_id: "root".to_string(),
187            id: node_id_str(id),
188            before_id: None,
189        }
190    }
191
192    pub fn move_node(parent_id: NodeId, id: NodeId, before_id: Option<NodeId>) -> Self {
193        Self::Move {
194            parent_id: node_id_str(parent_id),
195            id: node_id_str(id),
196            before_id: before_id.map(node_id_str),
197        }
198    }
199
200    pub fn remove(id: NodeId) -> Self {
201        Self::Remove {
202            id: node_id_str(id),
203        }
204    }
205}