Skip to main content

hypen_server/
state.rs

1use serde::{de::DeserializeOwned, Serialize};
2use serde_json::Value;
3
4use crate::error::{Result, SdkError};
5
6/// Trait for types that can be used as module state.
7///
8/// Any struct that implements `Serialize + DeserializeOwned + Clone + Send + Sync`
9/// can serve as module state. The SDK uses serde to convert between the typed
10/// state and JSON, and diffs JSON snapshots to detect which paths changed.
11///
12/// # Example
13///
14/// ```rust
15/// use serde::{Deserialize, Serialize};
16///
17/// #[derive(Clone, Default, Serialize, Deserialize)]
18/// struct CounterState {
19///     count: i32,
20///     label: String,
21/// }
22/// ```
23pub trait State: Serialize + DeserializeOwned + Clone + Send + Sync + 'static {}
24
25/// Blanket implementation: any type meeting the bounds is automatically `State`.
26impl<T> State for T where T: Serialize + DeserializeOwned + Clone + Send + Sync + 'static {}
27
28/// Holds the typed state alongside its JSON representation for efficient diffing.
29pub(crate) struct StateContainer<S: State> {
30    /// The typed state value — what users interact with.
31    value: S,
32    /// JSON snapshot taken *before* the most recent handler ran.
33    /// Used to compute changed paths after mutation.
34    snapshot: Value,
35}
36
37impl<S: State> StateContainer<S> {
38    /// Create a new container, serializing the initial state to JSON.
39    pub fn new(initial: S) -> Result<Self> {
40        let snapshot =
41            serde_json::to_value(&initial).map_err(|e| SdkError::StateSerde(e.to_string()))?;
42        Ok(Self {
43            value: initial,
44            snapshot,
45        })
46    }
47
48    /// Borrow the current state immutably.
49    pub fn get(&self) -> &S {
50        &self.value
51    }
52
53    /// Borrow the current state mutably (for action handlers).
54    pub fn get_mut(&mut self) -> &mut S {
55        &mut self.value
56    }
57
58    /// Take a snapshot of the current state (call *before* a handler mutates it).
59    pub fn take_snapshot(&mut self) -> Result<()> {
60        self.snapshot =
61            serde_json::to_value(&self.value).map_err(|e| SdkError::StateSerde(e.to_string()))?;
62        Ok(())
63    }
64
65    /// Compare the current state against the last snapshot and return
66    /// the list of dot-separated paths that changed.
67    ///
68    /// This is the core mechanism: handlers just do `state.count += 1`,
69    /// and we diff before/after to find `["count"]`.
70    ///
71    /// Delegates to the canonical [`hypen_engine::diff_paths`] — there is
72    /// exactly one implementation of this algorithm across all Hypen
73    /// SDKs, and it lives in the engine crate.
74    pub fn changed_paths(&self) -> Result<Vec<String>> {
75        let current =
76            serde_json::to_value(&self.value).map_err(|e| SdkError::StateSerde(e.to_string()))?;
77        Ok(hypen_engine::diff_paths(&self.snapshot, &current)
78            .into_iter()
79            .map(|e| e.path)
80            .collect())
81    }
82
83    /// Get the current state as a JSON value (for the engine).
84    pub fn to_json(&self) -> Result<Value> {
85        serde_json::to_value(&self.value).map_err(|e| SdkError::StateSerde(e.to_string()))
86    }
87
88    /// Build a JSON patch object that the engine's `update_state`
89    /// (object-merge semantics) can apply correctly.
90    ///
91    /// We roll every changed path up to its **root** segment and emit
92    /// the whole new subtree under that root. The engine's `merge_json`
93    /// only understands literal top-level keys — emitting a dotted key
94    /// like `"tabs.0"` would create a sibling field, not write through
95    /// to `state.tabs[0]`, which silently left arrays unchanged on
96    /// growth/replace. Rolling up to `"tabs": [...]` makes the engine
97    /// state actually reflect what the SDK-side state holds.
98    ///
99    /// (Sparse path-level updates exist as
100    /// `Engine::update_state_sparse`; this method is for the
101    /// object-merge variant used by `sync_state_to_engine`.)
102    pub fn diff_patch(&self) -> Result<Value> {
103        let current =
104            serde_json::to_value(&self.value).map_err(|e| SdkError::StateSerde(e.to_string()))?;
105        let mut patch = serde_json::Map::new();
106        let Value::Object(current_map) = &current else {
107            return Ok(Value::Object(patch));
108        };
109        for entry in hypen_engine::diff_paths(&self.snapshot, &current) {
110            let root = match entry.path.split_once('.') {
111                Some((head, _)) => head,
112                None => entry.path.as_str(),
113            };
114            if patch.contains_key(root) {
115                continue;
116            }
117            if let Some(new_subtree) = current_map.get(root) {
118                patch.insert(root.to_string(), new_subtree.clone());
119            }
120        }
121        Ok(Value::Object(patch))
122    }
123}
124
125// ---------------------------------------------------------------------------
126// __hypen_bind two-way binding helpers
127// ---------------------------------------------------------------------------
128//
129// `__hypen_bind` is the engine-level reserved action name used by renderers
130// to push form-control values back into module state. The renderer dispatches
131// `__hypen_bind` with `{path, value}` payload; the SDK writes that value at
132// the dotted path inside the typed state via `hypen_engine::path_set`.
133//
134// See ENGINE_CONTRACT.md §13 for the cross-SDK contract.
135
136/// Apply a `__hypen_bind` payload to a typed state value via JSON round-trip.
137///
138/// Serializes `current` to JSON, calls [`hypen_engine::path_set`], and
139/// deserializes back to `S`. Errors out if the resulting JSON doesn't
140/// fit the type.
141pub(crate) fn apply_bind<S: State>(current: &S, path: &str, value: Value) -> Result<S> {
142    let mut json =
143        serde_json::to_value(current).map_err(|e| SdkError::StateSerde(e.to_string()))?;
144    hypen_engine::path_set(&mut json, path, value);
145    serde_json::from_value(json)
146        .map_err(|e| SdkError::StateSerde(format!("__hypen_bind apply at '{path}': {e}")))
147}
148
149/// JSON-side variant of [`apply_bind`] for the remote session. Returns
150/// `None` if the bind would produce a shape `S` cannot accept (silently
151/// dropped, mirroring TS/JS proxy semantics).
152pub(crate) fn apply_bind_to_json<S: State>(
153    state_json: &Value,
154    path: &str,
155    value: Value,
156) -> Option<Value> {
157    let mut new_json = state_json.clone();
158    hypen_engine::path_set(&mut new_json, path, value);
159    let typed: S = serde_json::from_value(new_json.clone()).ok()?;
160    serde_json::to_value(&typed).ok()
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use serde::{Deserialize, Serialize};
167    use serde_json::json;
168
169    #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
170    struct TestState {
171        count: i32,
172        name: String,
173        items: Vec<String>,
174    }
175
176    #[test]
177    fn test_diff_no_change() {
178        let container = StateContainer::new(TestState {
179            count: 0,
180            name: "Alice".into(),
181            items: vec![],
182        })
183        .unwrap();
184
185        let paths = container.changed_paths().unwrap();
186        assert!(paths.is_empty());
187    }
188
189    #[test]
190    fn test_diff_scalar_change() {
191        let mut container = StateContainer::new(TestState {
192            count: 0,
193            name: "Alice".into(),
194            items: vec![],
195        })
196        .unwrap();
197
198        container.take_snapshot().unwrap();
199        container.get_mut().count = 42;
200
201        let paths = container.changed_paths().unwrap();
202        assert_eq!(paths, vec!["count"]);
203    }
204
205    #[test]
206    fn test_diff_multiple_changes() {
207        let mut container = StateContainer::new(TestState {
208            count: 0,
209            name: "Alice".into(),
210            items: vec![],
211        })
212        .unwrap();
213
214        container.take_snapshot().unwrap();
215        container.get_mut().count = 10;
216        container.get_mut().name = "Bob".into();
217
218        let mut paths = container.changed_paths().unwrap();
219        paths.sort();
220        assert_eq!(paths, vec!["count", "name"]);
221    }
222
223    #[test]
224    fn test_diff_array_change() {
225        let mut container = StateContainer::new(TestState {
226            count: 0,
227            name: "Alice".into(),
228            items: vec!["a".into()],
229        })
230        .unwrap();
231
232        container.take_snapshot().unwrap();
233        container.get_mut().items.push("b".into());
234
235        let paths = container.changed_paths().unwrap();
236        assert!(paths.contains(&"items.1".to_string()));
237    }
238
239    #[test]
240    fn test_diff_nested_struct() {
241        #[derive(Clone, Default, Serialize, Deserialize)]
242        struct Nested {
243            user: User,
244            count: i32,
245        }
246
247        #[derive(Clone, Default, Serialize, Deserialize)]
248        struct User {
249            name: String,
250            age: i32,
251        }
252
253        let mut container = StateContainer::new(Nested {
254            user: User {
255                name: "Alice".into(),
256                age: 30,
257            },
258            count: 0,
259        })
260        .unwrap();
261
262        container.take_snapshot().unwrap();
263        container.get_mut().user.age = 31;
264
265        let paths = container.changed_paths().unwrap();
266        assert_eq!(paths, vec!["user.age"]);
267    }
268
269    #[test]
270    fn test_diff_delegates_to_engine() {
271        // Smoke-test that `changed_paths` routes through the engine's
272        // canonical implementation. The deep coverage lives in the
273        // engine crate (hypen_engine::portable::diff).
274        let old = json!({"a": 1, "b": {"c": 2, "d": 3}});
275        let new = json!({"a": 1, "b": {"c": 99, "d": 3}, "e": true});
276        let paths: Vec<String> = hypen_engine::diff_paths(&old, &new)
277            .into_iter()
278            .map(|e| e.path)
279            .collect();
280        assert!(paths.contains(&"b.c".to_string()));
281        assert!(paths.contains(&"e".to_string()));
282        assert!(!paths.contains(&"a".to_string()));
283        assert!(!paths.contains(&"b.d".to_string()));
284    }
285
286    #[test]
287    fn test_diff_patch_output() {
288        let mut container = StateContainer::new(TestState {
289            count: 0,
290            name: "Alice".into(),
291            items: vec![],
292        })
293        .unwrap();
294
295        container.take_snapshot().unwrap();
296        container.get_mut().count = 5;
297
298        let patch = container.diff_patch().unwrap();
299        assert_eq!(patch, json!({"count": 5}));
300    }
301
302    #[test]
303    fn diff_patch_rolls_up_growing_vec_to_root_subtree() {
304        // Regression: when a Vec field grew from [] → [item], the diff
305        // produced path "items.0" and the old diff_patch emitted a
306        // literal `"items.0"` key. The engine's merge_json then inserted
307        // that key as a SIBLING of `items`, leaving `state.items` as
308        // `[]` from the engine's perspective — ForEach iterating over
309        // `@state.items` rendered nothing and `length(state.items)` was
310        // 0 despite the SDK-side Vec being populated.
311        let mut container = StateContainer::new(TestState {
312            count: 0,
313            name: "x".into(),
314            items: vec![],
315        })
316        .unwrap();
317        container.take_snapshot().unwrap();
318        container.get_mut().items.push("first".into());
319
320        let patch = container.diff_patch().unwrap();
321        assert_eq!(
322            patch,
323            json!({"items": ["first"]}),
324            "growing vec must emit whole `items` subtree, not `items.0`",
325        );
326    }
327
328    #[test]
329    fn diff_patch_rolls_up_nested_object_change_to_root() {
330        // Same principle: a deep object change like `user.profile.name`
331        // becomes `{"user": <whole user subtree>}` rather than
332        // `{"user.profile.name": "..."}`, because merge_json only
333        // honours literal top-level keys.
334        #[derive(Clone, Default, Serialize, Deserialize, PartialEq, Debug)]
335        struct Profile {
336            name: String,
337        }
338        #[derive(Clone, Default, Serialize, Deserialize, PartialEq, Debug)]
339        struct User {
340            profile: Profile,
341            age: i32,
342        }
343        #[derive(Clone, Default, Serialize, Deserialize, PartialEq, Debug)]
344        struct S {
345            user: User,
346        }
347
348        let mut container = StateContainer::new(S {
349            user: User {
350                profile: Profile { name: "old".into() },
351                age: 30,
352            },
353        })
354        .unwrap();
355        container.take_snapshot().unwrap();
356        container.get_mut().user.profile.name = "new".into();
357
358        let patch = container.diff_patch().unwrap();
359        assert_eq!(
360            patch,
361            json!({"user": {"profile": {"name": "new"}, "age": 30}}),
362        );
363    }
364
365    #[test]
366    fn test_to_json() {
367        let container = StateContainer::new(TestState {
368            count: 42,
369            name: "Bob".into(),
370            items: vec!["x".into()],
371        })
372        .unwrap();
373
374        let json = container.to_json().unwrap();
375        assert_eq!(json["count"], 42);
376        assert_eq!(json["name"], "Bob");
377    }
378}