Skip to main content

vs_daemon/
page_state.rs

1//! Per-page in-memory state owned by the [`Daemon`](crate::Daemon).
2//!
3//! Each open page tracks its engine handle, URL, the most recent tree
4//! we sent to the agent, and the most recent state token. The cache is
5//! used by [`view`](crate::handlers) to compute deltas.
6
7use std::collections::HashSet;
8
9use vs_engine_webkit::PageHandle;
10use vs_protocol::{DeltaOp, Ref, StateToken, Tree};
11
12use crate::tokens;
13
14/// In-memory page state.
15#[derive(Debug)]
16pub struct PageState {
17    pub id: String,
18    pub url: String,
19    pub engine_handle: PageHandle,
20    /// The last tree we emitted to the agent. `None` until the first
21    /// `vs_view`.
22    pub last_tree: Option<Tree>,
23    /// Token corresponding to `last_tree`, or to the post-mutation
24    /// state if a write just landed.
25    pub last_token: Option<StateToken>,
26    /// Whether the next `vs_view` must emit a fresh full tree (e.g.
27    /// after `nav`, viewport change, or `auth_loaded`).
28    pub force_full: bool,
29    /// Refs ever seen on this page, for retire-tracking.
30    pub seen_refs: HashSet<Ref>,
31}
32
33impl PageState {
34    #[must_use]
35    pub fn new(id: String, url: String, engine_handle: PageHandle) -> Self {
36        Self {
37            id,
38            url,
39            engine_handle,
40            last_tree: None,
41            last_token: None,
42            force_full: true,
43            seen_refs: HashSet::new(),
44        }
45    }
46
47    /// Mark the next view as a fresh-full re-baseline.
48    pub fn invalidate_baseline(&mut self) {
49        self.force_full = true;
50    }
51
52    /// Apply a fresh snapshot from the engine. Returns the new token
53    /// and the [`ViewForm`] the agent should receive.
54    pub fn apply_snapshot(&mut self, new_tree: Tree) -> (StateToken, ViewForm) {
55        let token = tokens::compute(&new_tree, &self.url, &self.id);
56
57        // Same token = no change. Send NoChange even on a force_full
58        // marker — agents asking for a fresh full got the same one
59        // when nothing happened.
60        if !self.force_full && self.last_token.as_ref().is_some_and(|t| *t == token) {
61            return (token, ViewForm::NoChange);
62        }
63
64        let form = match (&self.last_tree, self.force_full) {
65            (None, _) | (Some(_), true) => ViewForm::Full(new_tree.clone()),
66            (Some(prev), false) => {
67                let ops = vs_protocol::diff(prev, &new_tree);
68                ViewForm::Delta(ops)
69            }
70        };
71
72        // Track refs seen on this page.
73        for node in &new_tree {
74            self.seen_refs.insert(node.r);
75        }
76
77        self.last_tree = Some(new_tree);
78        self.last_token = Some(token);
79        self.force_full = false;
80        (token, form)
81    }
82
83    /// Find a node by ref in the most recent tree. Used by `vs_read`.
84    #[must_use]
85    pub fn find_node(&self, r: Ref) -> Option<&vs_protocol::Node> {
86        self.last_tree
87            .as_ref()
88            .and_then(|t| t.iter().find(|n| n.r == r))
89    }
90}
91
92/// What [`PageState::apply_snapshot`] produced.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum ViewForm {
95    /// First view on the page (or post-baseline-reset). Emit the whole
96    /// tree.
97    Full(Tree),
98    /// Subsequent view. Emit the delta against the last-emitted tree.
99    Delta(Vec<DeltaOp>),
100    /// `state_token` unchanged from the last call. Body is empty.
101    NoChange,
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use vs_protocol::{Node, Role};
108
109    fn sample_tree(label: &str) -> Tree {
110        Tree::from_root(Node::leaf(Ref(1), Role::Doc, label))
111    }
112
113    fn make_state() -> PageState {
114        PageState::new("page-1".into(), "https://x".into(), PageHandle(1))
115    }
116
117    #[test]
118    fn first_snapshot_is_full() {
119        let mut p = make_state();
120        let (_token, form) = p.apply_snapshot(sample_tree("Hello"));
121        match form {
122            ViewForm::Full(tree) => assert_eq!(tree.roots[0].label, "Hello"),
123            other => panic!("unexpected: {other:?}"),
124        }
125    }
126
127    #[test]
128    fn second_snapshot_with_change_is_delta() {
129        let mut p = make_state();
130        p.apply_snapshot(sample_tree("Hello"));
131        let (_token, form) = p.apply_snapshot(sample_tree("World"));
132        assert!(matches!(form, ViewForm::Delta(_)));
133    }
134
135    #[test]
136    fn unchanged_snapshot_is_no_change() {
137        let mut p = make_state();
138        p.apply_snapshot(sample_tree("Hello"));
139        let (_token, form) = p.apply_snapshot(sample_tree("Hello"));
140        assert!(matches!(form, ViewForm::NoChange));
141    }
142
143    #[test]
144    fn baseline_reset_forces_full() {
145        let mut p = make_state();
146        p.apply_snapshot(sample_tree("Hello"));
147        p.invalidate_baseline();
148        let (_token, form) = p.apply_snapshot(sample_tree("Hello"));
149        assert!(matches!(form, ViewForm::Full(_)));
150    }
151
152    #[test]
153    fn token_changes_when_tree_changes() {
154        let mut p = make_state();
155        let (t1, _) = p.apply_snapshot(sample_tree("A"));
156        let (t2, _) = p.apply_snapshot(sample_tree("B"));
157        assert_ne!(t1, t2);
158    }
159
160    #[test]
161    fn refs_accumulate_in_seen_set() {
162        let mut p = make_state();
163        let mut t = Tree::from_root(Node::leaf(Ref(1), Role::Doc, "X"));
164        t.roots[0].children.push(Node::leaf(Ref(2), Role::P, "p1"));
165        p.apply_snapshot(t);
166        assert!(p.seen_refs.contains(&Ref(1)));
167        assert!(p.seen_refs.contains(&Ref(2)));
168    }
169
170    #[test]
171    fn find_node_returns_match() {
172        let mut p = make_state();
173        let mut t = Tree::from_root(Node::leaf(Ref(1), Role::Doc, "X"));
174        t.roots[0].children.push(Node::leaf(Ref(2), Role::P, "kid"));
175        p.apply_snapshot(t);
176        let node = p.find_node(Ref(2)).unwrap();
177        assert_eq!(node.label, "kid");
178    }
179}