beck_core/
diff.rs

1//! Structural diff of two `Html` values — the producer half of "the browser is
2//! `fold(apply_patch, initial_html, patch_stream)`".
3//!
4//! Which side produces the ops is the mode: in Mode A the server diffs two renderings and streams
5//! them; in Mode B the browser renders locally and diffs its own two renderings ([`crate::render`]).
6//! It is the same function either way, which is why it lives here rather than in the runtime.
7//!
8//! Properties this implementation holds, because the whole Mode A story rests on them:
9//!
10//! * **Skipping.** Equal structural hashes ⇒ no ops, no descent. A patch is O(changed), not
11//!   O(tree) (§5.1).
12//! * **Keyed children.** Lists reorder by `Move`, not by rebuilding — which is also what preserves
13//!   focus and scroll position in the browser (§5.1 "frame identity").
14//! * **Sequential application.** Ops are emitted in the order the client must apply them; each
15//!   index is valid against the DOM as it exists at that moment, which is why removals descend and
16//!   insertions ascend.
17
18use std::collections::HashSet;
19
20use serde_json::{json, Value};
21
22use crate::html::Html;
23
24/// A node address: child indices from the root of the subscription's frame.
25pub type Path = Vec<u32>;
26
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub enum Op {
29    /// Replace the node at `path` wholesale (tag or key changed).
30    Replace {
31        path: Path,
32        html: Html,
33    },
34    SetText {
35        path: Path,
36        text: String,
37    },
38    SetAttr {
39        path: Path,
40        name: String,
41        value: String,
42    },
43    RemoveAttr {
44        path: Path,
45        name: String,
46    },
47    /// Insert `html` as child `index` of the element at `path`.
48    Insert {
49        path: Path,
50        index: u32,
51        html: Html,
52    },
53    Remove {
54        path: Path,
55        index: u32,
56    },
57    /// Move child `from` to `to` within the element at `path`; `from > to` always.
58    Move {
59        path: Path,
60        from: u32,
61        to: u32,
62    },
63}
64
65impl Op {
66    /// Wire encoding: a positional array whose head is the op tag.
67    pub fn to_wire(&self) -> Value {
68        match self {
69            Op::Replace { path, html } => json!([0, path, html.to_wire()]),
70            Op::SetText { path, text } => json!([1, path, text]),
71            Op::SetAttr { path, name, value } => json!([2, path, name, value]),
72            Op::RemoveAttr { path, name } => json!([3, path, name]),
73            Op::Insert { path, index, html } => json!([4, path, index, html.to_wire()]),
74            Op::Remove { path, index } => json!([5, path, index]),
75            Op::Move { path, from, to } => json!([6, path, from, to]),
76        }
77    }
78}
79
80/// Diff two views of the same frame.
81pub fn diff(old: &Html, new: &Html) -> Vec<Op> {
82    let mut ops = Vec::new();
83    let mut path = Vec::new();
84    diff_node(old, new, &mut path, &mut ops);
85    ops
86}
87
88fn diff_node(old: &Html, new: &Html, path: &mut Path, ops: &mut Vec<Op>) {
89    if old.hash() == new.hash() {
90        return; // the subtree provably cannot have changed
91    }
92    match (old, new) {
93        (Html::Text { .. }, Html::Text { text, .. }) => ops.push(Op::SetText {
94            path: path.clone(),
95            text: text.clone(),
96        }),
97        (
98            Html::Element {
99                tag: old_tag,
100                key: old_key,
101                attrs: old_attrs,
102                children: old_children,
103                ..
104            },
105            Html::Element {
106                tag: new_tag,
107                key: new_key,
108                attrs: new_attrs,
109                children: new_children,
110                ..
111            },
112        ) if old_tag == new_tag && old_key == new_key => {
113            diff_attrs(old_attrs, new_attrs, path, ops);
114            diff_children(old_children, new_children, path, ops);
115        }
116        _ => ops.push(Op::Replace {
117            path: path.clone(),
118            html: new.clone(),
119        }),
120    }
121}
122
123fn diff_attrs(old: &[(String, String)], new: &[(String, String)], path: &Path, ops: &mut Vec<Op>) {
124    for (name, value) in new {
125        match old.iter().find(|(k, _)| k == name) {
126            Some((_, old_value)) if old_value == value => {}
127            _ => ops.push(Op::SetAttr {
128                path: path.clone(),
129                name: name.clone(),
130                value: value.clone(),
131            }),
132        }
133    }
134    for (name, _) in old {
135        if !new.iter().any(|(k, _)| k == name) {
136            ops.push(Op::RemoveAttr {
137                path: path.clone(),
138                name: name.clone(),
139            });
140        }
141    }
142}
143
144fn diff_children(old: &[Html], new: &[Html], path: &mut Path, ops: &mut Vec<Op>) {
145    if keyed(old) && keyed(new) {
146        diff_keyed(old, new, path, ops);
147    } else {
148        diff_positional(old, new, path, ops);
149    }
150}
151
152/// Keyed iff every child carries a key and the keys are unique — otherwise the reconciliation
153/// below would be ambiguous, and a positional diff is the honest fallback.
154fn keyed(children: &[Html]) -> bool {
155    if children.is_empty() {
156        return false;
157    }
158    let mut seen = HashSet::with_capacity(children.len());
159    children.iter().all(|c| match c.key_of() {
160        Some(k) => seen.insert(k),
161        None => false,
162    })
163}
164
165fn diff_positional(old: &[Html], new: &[Html], path: &mut Path, ops: &mut Vec<Op>) {
166    let common = old.len().min(new.len());
167    for i in 0..common {
168        path.push(i as u32);
169        diff_node(&old[i], &new[i], path, ops);
170        path.pop();
171    }
172    for i in (common..old.len()).rev() {
173        ops.push(Op::Remove {
174            path: path.clone(),
175            index: i as u32,
176        });
177    }
178    for (i, node) in new.iter().enumerate().skip(common) {
179        ops.push(Op::Insert {
180            path: path.clone(),
181            index: i as u32,
182            html: node.clone(),
183        });
184    }
185}
186
187fn diff_keyed(old: &[Html], new: &[Html], path: &mut Path, ops: &mut Vec<Op>) {
188    let wanted: HashSet<&str> = new.iter().filter_map(Html::key_of).collect();
189
190    // `cursor` mirrors the client's child list as the ops are applied, so every index emitted
191    // below is the index the client will see at that point in the stream.
192    let mut cursor: Vec<&Html> = old.iter().collect();
193
194    let mut i = 0;
195    while i < cursor.len() {
196        if cursor[i].key_of().is_some_and(|k| wanted.contains(k)) {
197            i += 1;
198        } else {
199            ops.push(Op::Remove {
200                path: path.clone(),
201                index: i as u32,
202            });
203            cursor.remove(i);
204        }
205    }
206
207    for (j, want) in new.iter().enumerate() {
208        let key = want.key_of();
209        let found = cursor[j..].iter().position(|c| c.key_of() == key);
210        match found {
211            Some(0) => {}
212            Some(offset) => {
213                let from = (j + offset) as u32;
214                ops.push(Op::Move {
215                    path: path.clone(),
216                    from,
217                    to: j as u32,
218                });
219                let node = cursor.remove(j + offset);
220                cursor.insert(j, node);
221            }
222            None => {
223                ops.push(Op::Insert {
224                    path: path.clone(),
225                    index: j as u32,
226                    html: want.clone(),
227                });
228                cursor.insert(j, want);
229                continue; // freshly inserted: nothing to diff against
230            }
231        }
232        path.push(j as u32);
233        diff_node(cursor[j], want, path, ops);
234        path.pop();
235    }
236}
237
238/// Apply a patch to an `Html` value — the server-side model of what the browser does.
239///
240/// This exists so the differ can be tested against its own client: `apply(old, diff(old, new)) ==
241/// new` is a property, checked below and re-checked by the end-to-end harness against the real
242/// browser. It is the Phase 0 stand-in for §4.8's differential harness.
243pub fn apply(root: &Html, ops: &[Op]) -> Html {
244    let mut root = root.clone();
245    for op in ops {
246        match op {
247            Op::Replace { path, html } => set_node(&mut root, path, html.clone()),
248            Op::SetText { path, text } => set_node(&mut root, path, Html::text(text.clone())),
249            Op::SetAttr { path, name, value } => {
250                let target = node_mut(&mut root, path);
251                *target = with_attr(target.clone(), name, Some(value));
252            }
253            Op::RemoveAttr { path, name } => {
254                let target = node_mut(&mut root, path);
255                *target = with_attr(target.clone(), name, None);
256            }
257            Op::Insert { path, index, html } => {
258                let parent = node_mut(&mut root, path);
259                *parent = rebuild(parent.clone(), |cs| {
260                    cs.insert(*index as usize, html.clone());
261                });
262            }
263            Op::Remove { path, index } => {
264                let parent = node_mut(&mut root, path);
265                *parent = rebuild(parent.clone(), |cs| {
266                    cs.remove(*index as usize);
267                });
268            }
269            Op::Move { path, from, to } => {
270                let parent = node_mut(&mut root, path);
271                *parent = rebuild(parent.clone(), |cs| {
272                    let node = cs.remove(*from as usize);
273                    cs.insert(*to as usize, node);
274                });
275            }
276        }
277    }
278    // Every op invalidates the structural hash of the patched node's ancestors, so the tree is
279    // rehashed once at the end rather than repaired op by op.
280    root.rehash()
281}
282
283fn node_mut<'a>(root: &'a mut Html, path: &[u32]) -> &'a mut Html {
284    let mut node = root;
285    for step in path {
286        node = match node {
287            Html::Element { children, .. } => &mut children[*step as usize],
288            Html::Text { .. } => panic!("patch path descends into a text node"),
289        };
290    }
291    node
292}
293
294fn set_node(root: &mut Html, path: &[u32], value: Html) {
295    *node_mut(root, path) = value;
296}
297
298/// Rebuild an element through the `Html` builder so the structural hash stays consistent.
299fn rebuild(node: Html, f: impl FnOnce(&mut Vec<Html>)) -> Html {
300    match node {
301        Html::Element {
302            tag,
303            attrs,
304            key,
305            mut children,
306            ..
307        } => {
308            f(&mut children);
309            let mut el = Html::el(tag);
310            for (k, v) in attrs {
311                el = el.attr(k, v);
312            }
313            if let Some(k) = key {
314                el = el.key(k);
315            }
316            el.children(children)
317        }
318        text => text,
319    }
320}
321
322fn with_attr(node: Html, name: &str, value: Option<&str>) -> Html {
323    match node {
324        Html::Element {
325            tag,
326            attrs,
327            key,
328            children,
329            ..
330        } => {
331            let mut el = Html::el(tag);
332            let mut replaced = false;
333            for (k, v) in attrs {
334                if k == name {
335                    replaced = true;
336                    match value {
337                        Some(new) => el = el.attr(k, new),
338                        None => continue,
339                    }
340                } else {
341                    el = el.attr(k, v);
342                }
343            }
344            if !replaced {
345                if let Some(new) = value {
346                    el = el.attr(name, new);
347                }
348            }
349            if let Some(k) = key {
350                el = el.key(k);
351            }
352            el.children(children)
353        }
354        text => text,
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn li(key: &str, text: &str, done: bool) -> Html {
363        Html::el("li")
364            .key(key)
365            .attr_if(done, "class", "done")
366            .child(Html::text(text))
367    }
368
369    fn list(items: Vec<Html>) -> Html {
370        Html::el("main")
371            .child(Html::el("h1").child(Html::text("todos")))
372            .child(Html::el("ul").children(items))
373    }
374
375    #[test]
376    fn identical_trees_produce_no_ops() {
377        let a = list(vec![li("1", "a", false), li("2", "b", false)]);
378        let b = list(vec![li("1", "a", false), li("2", "b", false)]);
379        assert!(diff(&a, &b).is_empty());
380    }
381
382    #[test]
383    fn a_toggle_touches_one_attribute_and_nothing_else() {
384        let a = list(vec![li("1", "a", false), li("2", "b", false)]);
385        let b = list(vec![li("1", "a", true), li("2", "b", false)]);
386        let ops = diff(&a, &b);
387        assert_eq!(
388            ops,
389            vec![Op::SetAttr {
390                path: vec![1, 0],
391                name: "class".into(),
392                value: "done".into(),
393            }]
394        );
395        assert_eq!(apply(&a, &ops), b);
396    }
397
398    #[test]
399    fn reordering_moves_rather_than_rebuilds() {
400        let a = list(vec![
401            li("1", "a", false),
402            li("2", "b", false),
403            li("3", "c", false),
404        ]);
405        let b = list(vec![
406            li("3", "c", false),
407            li("1", "a", false),
408            li("2", "b", false),
409        ]);
410        let ops = diff(&a, &b);
411        assert_eq!(
412            ops,
413            vec![Op::Move {
414                path: vec![1],
415                from: 2,
416                to: 0
417            }]
418        );
419        assert_eq!(apply(&a, &ops), b);
420    }
421
422    #[test]
423    fn insert_remove_and_edit_compose() {
424        let a = list(vec![
425            li("1", "a", false),
426            li("2", "b", false),
427            li("3", "c", false),
428        ]);
429        let b = list(vec![
430            li("2", "b!", true),
431            li("4", "d", false),
432            li("1", "a", false),
433        ]);
434        let ops = diff(&a, &b);
435        assert_eq!(apply(&a, &ops), b, "ops: {ops:#?}");
436    }
437
438    #[test]
439    fn unkeyed_children_fall_back_to_positional() {
440        let a = Html::el("footer").child(Html::text("2 remaining"));
441        let b = Html::el("footer").child(Html::text("1 remaining"));
442        let ops = diff(&a, &b);
443        assert_eq!(
444            ops,
445            vec![Op::SetText {
446                path: vec![0],
447                text: "1 remaining".into()
448            }]
449        );
450        assert_eq!(apply(&a, &ops), b);
451    }
452
453    #[test]
454    fn tag_change_replaces() {
455        let a = Html::el("main").child(Html::el("p").child(Html::text("x")));
456        let b = Html::el("main").child(Html::el("div").child(Html::text("x")));
457        let ops = diff(&a, &b);
458        assert!(matches!(ops.as_slice(), [Op::Replace { .. }]));
459        assert_eq!(apply(&a, &ops), b);
460    }
461
462    #[test]
463    fn round_trips_over_a_long_random_walk() {
464        // A cheap deterministic PRNG: this is a test, and reproducibility beats entropy.
465        let mut seed = 0x243f_6a88_85a3_08d3u64;
466        let mut rand = move || {
467            seed ^= seed << 13;
468            seed ^= seed >> 7;
469            seed ^= seed << 17;
470            seed
471        };
472
473        let mut items: Vec<(u32, String, bool)> = Vec::new();
474        let mut next_key = 0u32;
475        let mut current = list(vec![]);
476
477        for _ in 0..400 {
478            match rand() % 4 {
479                0 => {
480                    next_key += 1;
481                    let at = if items.is_empty() {
482                        0
483                    } else {
484                        (rand() as usize) % (items.len() + 1)
485                    };
486                    items.insert(at, (next_key, format!("todo {next_key}"), false));
487                }
488                1 if !items.is_empty() => {
489                    let at = (rand() as usize) % items.len();
490                    items.remove(at);
491                }
492                2 if !items.is_empty() => {
493                    let at = (rand() as usize) % items.len();
494                    items[at].2 = !items[at].2;
495                }
496                3 if items.len() > 1 => {
497                    let from = (rand() as usize) % items.len();
498                    let to = (rand() as usize) % items.len();
499                    let item = items.remove(from);
500                    items.insert(to, item);
501                }
502                _ => {}
503            }
504
505            let next = list(
506                items
507                    .iter()
508                    .map(|(k, t, d)| li(&k.to_string(), t, *d))
509                    .collect(),
510            );
511            let ops = diff(&current, &next);
512            assert_eq!(apply(&current, &ops), next, "ops: {ops:#?}");
513            current = next;
514        }
515    }
516}