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::{HashMap, HashSet};
19use std::sync::Arc;
20
21use serde_json::{json, Value};
22
23use crate::html::Html;
24
25/// A node address: child indices from the root of the subscription's frame.
26pub type Path = Vec<u32>;
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum Op {
30    /// Replace the node at `path` wholesale (tag or key changed).
31    Replace {
32        path: Path,
33        html: Html,
34    },
35    SetText {
36        path: Path,
37        text: String,
38    },
39    SetAttr {
40        path: Path,
41        name: String,
42        value: String,
43    },
44    RemoveAttr {
45        path: Path,
46        name: String,
47    },
48    /// Insert `html` as child `index` of the element at `path`.
49    Insert {
50        path: Path,
51        index: u32,
52        html: Html,
53    },
54    Remove {
55        path: Path,
56        index: u32,
57    },
58    /// Move child `from` to `to` within the element at `path`; `from > to` always.
59    Move {
60        path: Path,
61        from: u32,
62        to: u32,
63    },
64}
65
66impl Op {
67    /// Wire encoding: a positional array whose head is the op tag.
68    pub fn to_wire(&self) -> Value {
69        match self {
70            Op::Replace { path, html } => json!([0, path, html.to_wire()]),
71            Op::SetText { path, text } => json!([1, path, text]),
72            Op::SetAttr { path, name, value } => json!([2, path, name, value]),
73            Op::RemoveAttr { path, name } => json!([3, path, name]),
74            Op::Insert { path, index, html } => json!([4, path, index, html.to_wire()]),
75            Op::Remove { path, index } => json!([5, path, index]),
76            Op::Move { path, from, to } => json!([6, path, from, to]),
77        }
78    }
79}
80
81/// Diff two views of the same frame.
82pub fn diff(old: &Html, new: &Html) -> Vec<Op> {
83    let mut ops = Vec::new();
84    let mut path = Vec::new();
85    diff_node(old, new, &mut path, &mut ops);
86    ops
87}
88
89fn diff_node(old: &Html, new: &Html, path: &mut Path, ops: &mut Vec<Op>) {
90    if old.hash() == new.hash() {
91        return; // the subtree provably cannot have changed
92    }
93    match (old, new) {
94        (Html::Text { .. }, Html::Text { text, .. }) => ops.push(Op::SetText {
95            path: path.clone(),
96            text: text.clone(),
97        }),
98        (
99            Html::Element {
100                tag: old_tag,
101                key: old_key,
102                attrs: old_attrs,
103                children: old_children,
104                ..
105            },
106            Html::Element {
107                tag: new_tag,
108                key: new_key,
109                attrs: new_attrs,
110                children: new_children,
111                ..
112            },
113        ) if old_tag == new_tag && old_key == new_key => {
114            diff_attrs(old_attrs, new_attrs, path, ops);
115            diff_children(old_children, new_children, path, ops);
116        }
117        _ => ops.push(Op::Replace {
118            path: path.clone(),
119            html: new.clone(),
120        }),
121    }
122}
123
124fn diff_attrs(old: &[(String, String)], new: &[(String, String)], path: &Path, ops: &mut Vec<Op>) {
125    for (name, value) in new {
126        match old.iter().find(|(k, _)| k == name) {
127            Some((_, old_value)) if old_value == value => {}
128            _ => ops.push(Op::SetAttr {
129                path: path.clone(),
130                name: name.clone(),
131                value: value.clone(),
132            }),
133        }
134    }
135    for (name, _) in old {
136        if !new.iter().any(|(k, _)| k == name) {
137            ops.push(Op::RemoveAttr {
138                path: path.clone(),
139                name: name.clone(),
140            });
141        }
142    }
143}
144
145fn diff_children(old: &[Arc<Html>], new: &[Arc<Html>], path: &mut Path, ops: &mut Vec<Op>) {
146    let (head, tail) = shared_ends(old, new);
147    if both_keyed(old, new, head, tail) {
148        diff_keyed_from(
149            &old[head..old.len() - tail],
150            &new[head..new.len() - tail],
151            head as u32,
152            path,
153            ops,
154        );
155    } else {
156        // The full lists, deliberately. Trimming here would make a shared page and a copied one
157        // patch differently — `Remove` and `Insert` are index-based, so dropping a shared suffix
158        // moves the indices the positional path emits. `diff::tests::
159        // a_shared_page_and_a_copied_one_produce_the_same_ops` is the property that forbids it.
160        diff_positional(old, new, path, ops);
161    }
162}
163
164/// How many children the two lists hold as the *same allocation* at each end.
165///
166/// An untouched child is literally the node it was, so a run of them needs no ops and no
167/// examination. This is a fact about how the page was assembled rather than about what it
168/// contains, which is why nothing downstream may let it change the ops it emits.
169fn shared_ends(old: &[Arc<Html>], new: &[Arc<Html>]) -> (usize, usize) {
170    let mut head = 0;
171    while head < old.len() && head < new.len() && Arc::ptr_eq(&old[head], &new[head]) {
172        head += 1;
173    }
174    let mut tail = 0;
175    while head + tail < old.len()
176        && head + tail < new.len()
177        && Arc::ptr_eq(&old[old.len() - 1 - tail], &new[new.len() - 1 - tail])
178    {
179        tail += 1;
180    }
181    (head, tail)
182}
183
184/// `keyed(old) && keyed(new)`, computed once over what the two lists share instead of twice.
185///
186/// Whether a list reconciles by key is a question about the **whole** list — a key repeated
187/// anywhere makes the reconciliation ambiguous — so this cannot be narrowed to the window the way
188/// the reconciliation itself is. But the children at the shared ends are the same allocations in
189/// both lists and therefore carry the same keys, so hashing them once answers for both: only the
190/// windows differ, and only the windows need hashing twice.
191///
192/// That is worth having because this check, not the reconciliation, is what one event pays. On a
193/// page where a single row changed, the trim leaves a window of one and the two full-list passes
194/// were **62% of the diff at 1,000 rows, 87% at 5,000 and 89% at 8,000**.
195///
196/// The answer is unchanged, and that is the point: this is the same predicate, so no op moves.
197fn both_keyed(old: &[Arc<Html>], new: &[Arc<Html>], head: usize, tail: usize) -> bool {
198    // `keyed` was false for an empty list, and an empty list on either side still means the
199    // positional path.
200    if old.is_empty() || new.is_empty() {
201        return false;
202    }
203    let mut seen: HashSet<&str> = HashSet::with_capacity(old.len());
204    // The shared ends, hashed once. Their keys are the same in both lists by construction.
205    let shared = old[..head]
206        .iter()
207        .chain(&old[old.len() - tail..])
208        .map(|c| c.key_of());
209    for key in shared {
210        match key {
211            Some(k) if seen.insert(k) => {}
212            _ => return false,
213        }
214    }
215    // Each window against those, and against itself. The first window is taken back out so the
216    // second is measured against the shared ends alone.
217    let old_window = &old[head..old.len() - tail];
218    let new_window = &new[head..new.len() - tail];
219    for window in [old_window, new_window] {
220        for child in window {
221            match child.key_of() {
222                Some(k) if seen.insert(k) => {}
223                _ => return false,
224            }
225        }
226        for child in window {
227            seen.remove(child.key_of().expect("just inserted"));
228        }
229    }
230    true
231}
232
233fn diff_positional(old: &[Arc<Html>], new: &[Arc<Html>], path: &mut Path, ops: &mut Vec<Op>) {
234    let common = old.len().min(new.len());
235    for i in 0..common {
236        path.push(i as u32);
237        diff_node(&old[i], &new[i], path, ops);
238        path.pop();
239    }
240    for i in (common..old.len()).rev() {
241        ops.push(Op::Remove {
242            path: path.clone(),
243            index: i as u32,
244        });
245    }
246    for (i, node) in new.iter().enumerate().skip(common) {
247        ops.push(Op::Insert {
248            path: path.clone(),
249            index: i as u32,
250            html: (**node).clone(),
251        });
252    }
253}
254
255/// The reconciliation itself, over a window of the two child lists.
256///
257/// `base` is where that window starts in the client's children, so every index this emits is the
258/// index the client will see — the same contract the whole module keeps ("each index is valid
259/// against the DOM as it exists at that moment").
260fn diff_keyed_from(
261    old: &[Arc<Html>],
262    new: &[Arc<Html>],
263    base: u32,
264    path: &mut Path,
265    ops: &mut Vec<Op>,
266) {
267    let wanted: HashSet<&str> = new.iter().filter_map(|c| c.key_of()).collect();
268
269    // Drop the children the new list has no key for, in one pass. A child that survives lands at
270    // the index `kept.len()` had when it was reached, because every drop before it has already
271    // been applied — so the index each `Remove` carries is the one the client will see, without
272    // the list ever being shifted to find it.
273    let mut kept: Vec<&Html> = Vec::with_capacity(old.len().min(new.len()));
274    for c in old {
275        if c.key_of().is_some_and(|k| wanted.contains(k)) {
276            kept.push(c);
277        } else {
278            ops.push(Op::Remove {
279                path: path.clone(),
280                index: base + kept.len() as u32,
281            });
282        }
283    }
284
285    // Where each surviving child started, and it is what turns "find this key ahead of `j`" into
286    // a lookup. A key is taken out as it is claimed, so a list that repeats one — which `keyed`
287    // rules out before reconciliation is reached, but which this function should not depend on —
288    // treats the second occurrence as new, exactly as searching the remaining children did.
289    let mut origin: HashMap<&str, usize> = HashMap::with_capacity(kept.len());
290    for (p, c) in kept.iter().enumerate() {
291        if let Some(k) = c.key_of() {
292            origin.insert(k, p);
293        }
294    }
295
296    let mut unclaimed = Unclaimed::new(kept.len());
297    for (j, want) in new.iter().enumerate() {
298        let node = match want.key_of().and_then(|k| origin.remove(k)) {
299            Some(p) => {
300                // A `Move` lifts a child out and puts it back at `j`, so the children nobody has
301                // claimed yet keep their relative order: the one at `p` currently sits exactly as
302                // far past `j` as there are unclaimed children before it.
303                let offset = unclaimed.ahead_of(p);
304                if offset > 0 {
305                    ops.push(Op::Move {
306                        path: path.clone(),
307                        from: base + (j + offset) as u32,
308                        to: base + j as u32,
309                    });
310                }
311                unclaimed.claim(p);
312                kept[p]
313            }
314            None => {
315                ops.push(Op::Insert {
316                    path: path.clone(),
317                    index: base + j as u32,
318                    html: (**want).clone(),
319                });
320                continue; // freshly inserted: nothing to diff against
321            }
322        };
323        path.push(base + j as u32);
324        diff_node(node, want, path, ops);
325        path.pop();
326    }
327}
328
329/// How many of the client's children, ahead of a given one, the new list has not claimed yet.
330///
331/// A Fenwick tree over the surviving children's starting positions, holding one for each child
332/// still unclaimed. Phase two needs one number per child — the distance it currently sits ahead of
333/// where it belongs — and reading that off the child list is a scan, which made reconciliation
334/// quadratic in the window: reordering 4,000 keyed rows cost 25 ms of diffing, and doubling the
335/// rows quadrupled it. As a rank query it is `O(log w)`, so the pass is `O(w log w)`.
336///
337/// The op stream is unchanged. This computes the same offsets the scan did.
338struct Unclaimed {
339    /// One-based, as Fenwick trees are: `tree[0]` is unused so that `i & i.wrapping_neg()`
340    /// terminates.
341    tree: Vec<u32>,
342}
343
344impl Unclaimed {
345    /// All `n` children start unclaimed, built in `O(n)` by carrying each cell into its parent
346    /// rather than by `n` separate updates.
347    fn new(n: usize) -> Self {
348        let mut tree = vec![0u32; n + 1];
349        for i in 1..=n {
350            tree[i] += 1;
351            let parent = i + (i & i.wrapping_neg());
352            if parent <= n {
353                let carried = tree[i];
354                tree[parent] += carried;
355            }
356        }
357        Self { tree }
358    }
359
360    /// The number of still-unclaimed children whose starting position is before `p`.
361    fn ahead_of(&self, p: usize) -> usize {
362        let mut i = p;
363        let mut sum = 0u32;
364        while i > 0 {
365            sum += self.tree[i];
366            i -= i & i.wrapping_neg();
367        }
368        sum as usize
369    }
370
371    /// Mark the child that started at `p` as claimed, so it stops counting towards later offsets.
372    fn claim(&mut self, p: usize) {
373        let mut i = p + 1;
374        while i < self.tree.len() {
375            self.tree[i] -= 1;
376            i += i & i.wrapping_neg();
377        }
378    }
379}
380
381/// Apply a patch to an `Html` value — the server-side model of what the browser does.
382///
383/// This exists so the differ can be tested against its own client: `apply(old, diff(old, new)) ==
384/// new` is a property, checked below and re-checked by the end-to-end harness against the real
385/// browser. It is the Phase 0 stand-in for §4.8's differential harness.
386pub fn apply(root: &Html, ops: &[Op]) -> Html {
387    let mut root = root.clone();
388    for op in ops {
389        match op {
390            Op::Replace { path, html } => set_node(&mut root, path, html.clone()),
391            Op::SetText { path, text } => set_node(&mut root, path, Html::text(text.clone())),
392            Op::SetAttr { path, name, value } => {
393                let target = node_mut(&mut root, path);
394                *target = with_attr(target.clone(), name, Some(value));
395            }
396            Op::RemoveAttr { path, name } => {
397                let target = node_mut(&mut root, path);
398                *target = with_attr(target.clone(), name, None);
399            }
400            Op::Insert { path, index, html } => {
401                let parent = node_mut(&mut root, path);
402                *parent = rebuild(parent.clone(), |cs| {
403                    cs.insert(*index as usize, Arc::new(html.clone()));
404                });
405            }
406            Op::Remove { path, index } => {
407                let parent = node_mut(&mut root, path);
408                *parent = rebuild(parent.clone(), |cs| {
409                    cs.remove(*index as usize);
410                });
411            }
412            Op::Move { path, from, to } => {
413                let parent = node_mut(&mut root, path);
414                *parent = rebuild(parent.clone(), |cs| {
415                    let node = cs.remove(*from as usize);
416                    cs.insert(*to as usize, node);
417                });
418            }
419        }
420    }
421    // Every op invalidates the structural hash of the patched node's ancestors, so the tree is
422    // rehashed once at the end rather than repaired op by op.
423    root.rehash()
424}
425
426fn node_mut<'a>(root: &'a mut Html, path: &[u32]) -> &'a mut Html {
427    let mut node = root;
428    for step in path {
429        node = match node {
430            // `make_mut` and not an index: children are shared with whatever other tree holds
431            // them, so descending to patch one has to unshare exactly the spine it walks. Nodes
432            // off the path keep their allocation and their refcount.
433            Html::Element { children, .. } => Arc::make_mut(&mut children[*step as usize]),
434            Html::Text { .. } => panic!("patch path descends into a text node"),
435        };
436    }
437    node
438}
439
440fn set_node(root: &mut Html, path: &[u32], value: Html) {
441    *node_mut(root, path) = value;
442}
443
444/// Rebuild an element through the `Html` builder so the structural hash stays consistent.
445fn rebuild(node: Html, f: impl FnOnce(&mut Vec<Arc<Html>>)) -> Html {
446    match node {
447        Html::Element {
448            tag,
449            attrs,
450            key,
451            mut children,
452            ..
453        } => {
454            f(&mut children);
455            let mut el = Html::el(tag);
456            for (k, v) in attrs {
457                el = el.attr(k, v);
458            }
459            if let Some(k) = key {
460                el = el.key(k);
461            }
462            el.children_shared(children)
463        }
464        text => text,
465    }
466}
467
468fn with_attr(node: Html, name: &str, value: Option<&str>) -> Html {
469    match node {
470        Html::Element {
471            tag,
472            attrs,
473            key,
474            children,
475            ..
476        } => {
477            let mut el = Html::el(tag);
478            let mut replaced = false;
479            for (k, v) in attrs {
480                if k == name {
481                    replaced = true;
482                    match value {
483                        Some(new) => el = el.attr(k, new),
484                        None => continue,
485                    }
486                } else {
487                    el = el.attr(k, v);
488                }
489            }
490            if !replaced {
491                if let Some(new) = value {
492                    el = el.attr(name, new);
493                }
494            }
495            if let Some(k) = key {
496                el = el.key(k);
497            }
498            el.children_shared(children)
499        }
500        text => text,
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    fn li(key: &str, text: &str, done: bool) -> Html {
509        Html::el("li")
510            .key(key)
511            .attr_if(done, "class", "done")
512            .child(Html::text(text))
513    }
514
515    fn list(items: Vec<Html>) -> Html {
516        Html::el("main")
517            .child(Html::el("h1").child(Html::text("todos")))
518            .child(Html::el("ul").children(items))
519    }
520
521    #[test]
522    fn identical_trees_produce_no_ops() {
523        let a = list(vec![li("1", "a", false), li("2", "b", false)]);
524        let b = list(vec![li("1", "a", false), li("2", "b", false)]);
525        assert!(diff(&a, &b).is_empty());
526    }
527
528    #[test]
529    fn a_toggle_touches_one_attribute_and_nothing_else() {
530        let a = list(vec![li("1", "a", false), li("2", "b", false)]);
531        let b = list(vec![li("1", "a", true), li("2", "b", false)]);
532        let ops = diff(&a, &b);
533        assert_eq!(
534            ops,
535            vec![Op::SetAttr {
536                path: vec![1, 0],
537                name: "class".into(),
538                value: "done".into(),
539            }]
540        );
541        assert_eq!(apply(&a, &ops), b);
542    }
543
544    #[test]
545    fn reordering_moves_rather_than_rebuilds() {
546        let a = list(vec![
547            li("1", "a", false),
548            li("2", "b", false),
549            li("3", "c", false),
550        ]);
551        let b = list(vec![
552            li("3", "c", false),
553            li("1", "a", false),
554            li("2", "b", false),
555        ]);
556        let ops = diff(&a, &b);
557        assert_eq!(
558            ops,
559            vec![Op::Move {
560                path: vec![1],
561                from: 2,
562                to: 0
563            }]
564        );
565        assert_eq!(apply(&a, &ops), b);
566    }
567
568    #[test]
569    fn insert_remove_and_edit_compose() {
570        let a = list(vec![
571            li("1", "a", false),
572            li("2", "b", false),
573            li("3", "c", false),
574        ]);
575        let b = list(vec![
576            li("2", "b!", true),
577            li("4", "d", false),
578            li("1", "a", false),
579        ]);
580        let ops = diff(&a, &b);
581        assert_eq!(apply(&a, &ops), b, "ops: {ops:#?}");
582    }
583
584    #[test]
585    fn unkeyed_children_fall_back_to_positional() {
586        let a = Html::el("footer").child(Html::text("2 remaining"));
587        let b = Html::el("footer").child(Html::text("1 remaining"));
588        let ops = diff(&a, &b);
589        assert_eq!(
590            ops,
591            vec![Op::SetText {
592                path: vec![0],
593                text: "1 remaining".into()
594            }]
595        );
596        assert_eq!(apply(&a, &ops), b);
597    }
598
599    #[test]
600    fn tag_change_replaces() {
601        let a = Html::el("main").child(Html::el("p").child(Html::text("x")));
602        let b = Html::el("main").child(Html::el("div").child(Html::text("x")));
603        let ops = diff(&a, &b);
604        assert!(matches!(ops.as_slice(), [Op::Replace { .. }]));
605        assert_eq!(apply(&a, &ops), b);
606    }
607
608    /// **The same two pages, shared and copied, produce the same ops.**
609    ///
610    /// [`diff_keyed`] skips the runs of children the two lists hold as the *same allocation*, which
611    /// is a fact about how the page was assembled and not about what it contains. So the risk the
612    /// trim carries is precisely that it changes the answer — an index computed against a window
613    /// rather than the whole list, a move whose source is counted from the wrong place.
614    ///
615    /// `rehash` rebuilds a tree node for node, so the copy shares nothing and takes the full
616    /// reconciliation while the original takes the trimmed one. Every scenario below runs both and
617    /// requires the two op streams to be **equal**, and requires each to carry the client from the
618    /// old page to the new one. The unit tests above cannot do this job: they build every node
619    /// fresh, so nothing in them is ever shared and the trim never runs.
620    #[test]
621    fn a_shared_page_and_a_copied_one_produce_the_same_ops() {
622        fn shared(items: &[Arc<Html>]) -> Html {
623            let mut el = Html::el("ul");
624            for i in items {
625                el = el.child_shared(i.clone());
626            }
627            el
628        }
629        let pool: Vec<Arc<Html>> = (0..24)
630            .map(|i| Arc::new(li(&format!("k{i}"), &format!("item {i}"), i % 3 == 0)))
631            .collect();
632
633        // A deterministic walk over the shapes a page actually takes: prepend (the sketch's own),
634        // append, remove from either end and the middle, reorder, and an edit in place.
635        let mut seed = 0x5eedu64;
636        let mut rand = move |n: usize| {
637            seed = seed
638                .wrapping_mul(6364136223846793005)
639                .wrapping_add(1442695040888963407);
640            (seed >> 33) as usize % n.max(1)
641        };
642        let mut exercised = 0usize;
643        for case in 0..200 {
644            let take = 4 + rand(16);
645            let old_rows: Vec<Arc<Html>> = pool.iter().take(take).cloned().collect();
646            let mut new_rows = old_rows.clone();
647            match case % 6 {
648                0 => new_rows.insert(0, pool[23].clone()),
649                1 => new_rows.push(pool[23].clone()),
650                2 => {
651                    if !new_rows.is_empty() {
652                        new_rows.remove(rand(new_rows.len()));
653                    }
654                }
655                3 => new_rows.reverse(),
656                4 => {
657                    if new_rows.len() > 2 {
658                        let n = new_rows.len();
659                        new_rows.swap(1, n - 1);
660                    }
661                }
662                _ => {
663                    // An edit in place: a *different* node under a key that stays, which is what
664                    // makes the trim stop rather than run to the end.
665                    let at = rand(new_rows.len());
666                    new_rows[at] = Arc::new(li(&format!("k{at}"), "edited", true));
667                }
668            }
669            let (old, new) = (shared(&old_rows), shared(&new_rows));
670            // The same two pages with nothing shared: `rehash` rebuilds every node.
671            let (old_copy, new_copy) = (old.rehash(), new.rehash());
672            assert_eq!(old, old_copy, "rehash must preserve the value, case {case}");
673
674            let trimmed = diff(&old, &new);
675            let full = diff(&old_copy, &new_copy);
676            assert_eq!(
677                trimmed, full,
678                "case {case}: trimming the shared ends changed the ops"
679            );
680            assert_eq!(
681                apply(&old, &trimmed),
682                new.rehash(),
683                "case {case}: round trip"
684            );
685            if old_rows
686                .first()
687                .zip(new_rows.first())
688                .is_some_and(|(a, b)| Arc::ptr_eq(a, b))
689                || old_rows
690                    .last()
691                    .zip(new_rows.last())
692                    .is_some_and(|(a, b)| Arc::ptr_eq(a, b))
693            {
694                exercised += 1;
695            }
696        }
697        // The control: if nothing had a shared end, the two sides above would be the same code
698        // path and this test would prove nothing.
699        assert!(
700            exercised > 100,
701            "only {exercised} of 200 cases shared an end, so the trim was barely exercised"
702        );
703    }
704
705    /// **Whether a list reconciles by key is a question about the whole list, including the part
706    /// the two pages share.**
707    ///
708    /// [`both_keyed`] hashes the shared ends once instead of twice, which is only sound because it
709    /// still asks about every child. Narrowing the question to the window — which is what the
710    /// reconciliation itself is narrowed to, and so the tempting next step — would answer
711    /// differently here: the window below is cleanly keyed while the list holding it is not.
712    ///
713    /// Nothing else in this file can tell those two apart. Every other case builds children whose
714    /// keys are distinct, so a predicate that skipped the shared ends entirely passed all fifteen
715    /// of them; this is the one that goes red, which is the whole reason it exists
716    /// (`docs/82` §82.10).
717    #[test]
718    fn a_repeated_key_in_the_part_two_pages_share_forces_the_positional_path() {
719        // The duplicate sits in the prefix the two pages hold as the same allocations, and the
720        // window between them is a clean two-key reorder.
721        let dup_a = Arc::new(li("dup", "first", false));
722        let dup_b = Arc::new(li("dup", "second", false));
723        let x = Arc::new(li("x", "x", false));
724        let y = Arc::new(li("y", "y", false));
725        let end = Arc::new(li("end", "end", false));
726
727        let old = Html::el("ul").children_shared([
728            Arc::clone(&dup_a),
729            Arc::clone(&dup_b),
730            Arc::clone(&x),
731            Arc::clone(&y),
732            Arc::clone(&end),
733        ]);
734        let new = Html::el("ul").children_shared([
735            Arc::clone(&dup_a),
736            Arc::clone(&dup_b),
737            Arc::clone(&y),
738            Arc::clone(&x),
739            Arc::clone(&end),
740        ]);
741
742        let ops = diff(&old, &new);
743        assert!(
744            !ops.iter().any(|o| matches!(o, Op::Move { .. })),
745            "`dup` is carried by two children, so this list cannot reconcile by key and the \
746             positional path is the honest answer — but the ops moved something: {ops:?}"
747        );
748        assert_eq!(
749            apply(&old, &ops),
750            new.rehash(),
751            "and it still has to round trip"
752        );
753
754        // The same two pages with the duplicate resolved *do* reconcile by key, so the assertion
755        // above is about the repeat and not about the shape of the test.
756        let un_dup = Arc::new(li("dup2", "second", false));
757        let keyed_old = Html::el("ul").children_shared([
758            Arc::clone(&dup_a),
759            Arc::clone(&un_dup),
760            Arc::clone(&x),
761            Arc::clone(&y),
762            Arc::clone(&end),
763        ]);
764        let keyed_new = Html::el("ul").children_shared([
765            Arc::clone(&dup_a),
766            Arc::clone(&un_dup),
767            Arc::clone(&y),
768            Arc::clone(&x),
769            Arc::clone(&end),
770        ]);
771        let keyed_ops = diff(&keyed_old, &keyed_new);
772        assert!(
773            keyed_ops.iter().any(|o| matches!(o, Op::Move { .. })),
774            "with distinct keys the same reorder should move rather than rebuild: {keyed_ops:?}"
775        );
776        assert_eq!(apply(&keyed_old, &keyed_ops), keyed_new.rehash());
777    }
778
779    /// `diff_keyed_from` as it was before the rank structure: a scan of the child list for every
780    /// child. Kept as the oracle for the thing that replaced it.
781    fn scan_keyed_from(
782        old: &[Arc<Html>],
783        new: &[Arc<Html>],
784        base: u32,
785        path: &mut Path,
786        ops: &mut Vec<Op>,
787    ) {
788        let wanted: HashSet<&str> = new.iter().filter_map(|c| c.key_of()).collect();
789        let mut cursor: Vec<&Html> = old.iter().map(|c| &**c).collect();
790        let mut i = 0;
791        while i < cursor.len() {
792            if cursor[i].key_of().is_some_and(|k| wanted.contains(k)) {
793                i += 1;
794            } else {
795                ops.push(Op::Remove {
796                    path: path.clone(),
797                    index: base + i as u32,
798                });
799                cursor.remove(i);
800            }
801        }
802        for (j, want) in new.iter().enumerate() {
803            let key = want.key_of();
804            let found = cursor[j..].iter().position(|c| c.key_of() == key);
805            match found {
806                Some(0) => {}
807                Some(offset) => {
808                    ops.push(Op::Move {
809                        path: path.clone(),
810                        from: base + (j + offset) as u32,
811                        to: base + j as u32,
812                    });
813                    let node = cursor.remove(j + offset);
814                    cursor.insert(j, node);
815                }
816                None => {
817                    ops.push(Op::Insert {
818                        path: path.clone(),
819                        index: base + j as u32,
820                        html: (**want).clone(),
821                    });
822                    cursor.insert(j, want);
823                    continue;
824                }
825            }
826            path.push(base + j as u32);
827            diff_node(cursor[j], want, path, ops);
828            path.pop();
829        }
830    }
831
832    /// **The rank structure emits the same ops as the scan it replaced.**
833    ///
834    /// Reconciliation's output is a contract with a client that has already applied everything
835    /// before it, so replacing the scan had to be a faster route to the *same stream* rather than
836    /// merely to the same page. Round-tripping cannot see that difference — many distinct streams
837    /// land on the same tree, so a differ that emitted `n` redundant moves would still round-trip.
838    /// The scan is therefore kept above as the oracle and asserted against directly.
839    ///
840    /// The cases are built to reach all four outcomes — a child already in place, one that has to
841    /// move, one that is new, one that is dropped — and the run asserts it saw each, because a
842    /// generator that quietly stopped producing moves would leave this green while testing
843    /// nothing.
844    #[test]
845    fn the_rank_structure_and_the_scan_it_replaced_emit_the_same_ops() {
846        let mut seed = 0xd1ffu64;
847        let mut rand = move |n: usize| {
848            seed = seed
849                .wrapping_mul(6364136223846793005)
850                .wrapping_add(1442695040888963407);
851            (seed >> 33) as usize % n.max(1)
852        };
853        let (mut moved, mut inserted, mut removed, mut in_place) = (0usize, 0, 0, 0);
854
855        for case in 0..300 {
856            let n = rand(24);
857            let old: Vec<Arc<Html>> = (0..n)
858                .map(|i| Arc::new(li(&format!("k{i}"), &format!("row {i}"), false)))
859                .collect();
860
861            // Keep a random subset, permute it by repeated random rotation, edit some of the
862            // survivors' content, and splice in children with keys the old list never had.
863            let mut kept: Vec<Arc<Html>> = Vec::new();
864            for c in &old {
865                if rand(4) == 0 {
866                    continue;
867                }
868                if rand(3) == 0 {
869                    let key = c.key_of().expect("a keyed child").to_string();
870                    let done = rand(2) == 0;
871                    kept.push(Arc::new(li(&key, "edited", done)));
872                } else {
873                    kept.push(Arc::clone(c));
874                }
875            }
876            for _ in 0..rand(6) {
877                if !kept.is_empty() {
878                    let from = rand(kept.len());
879                    let to = rand(kept.len());
880                    let node = kept.remove(from);
881                    kept.insert(to, node);
882                }
883            }
884            for fresh in 0..rand(4) {
885                let at = rand(kept.len() + 1);
886                kept.insert(at, Arc::new(li(&format!("fresh{fresh}"), "new", false)));
887            }
888            let new = kept;
889
890            let mut fast = Vec::new();
891            let mut scan = Vec::new();
892            diff_keyed_from(&old, &new, 0, &mut Path::default(), &mut fast);
893            scan_keyed_from(&old, &new, 0, &mut Path::default(), &mut scan);
894            assert_eq!(
895                fast,
896                scan,
897                "case {case}: the rank structure diverged from the scan\n  old {:?}\n  new {:?}",
898                old.iter().map(|c| c.key_of()).collect::<Vec<_>>(),
899                new.iter().map(|c| c.key_of()).collect::<Vec<_>>(),
900            );
901
902            for op in &fast {
903                match op {
904                    Op::Move { .. } => moved += 1,
905                    Op::Insert { .. } => inserted += 1,
906                    Op::Remove { .. } => removed += 1,
907                    _ => {}
908                }
909            }
910            in_place += new.len().saturating_sub(
911                fast.iter()
912                    .filter(|o| matches!(o, Op::Move { .. } | Op::Insert { .. }))
913                    .count(),
914            );
915        }
916        assert!(
917            moved > 100 && inserted > 100 && removed > 100 && in_place > 100,
918            "the generator stopped covering an outcome: {moved} moves, {inserted} inserts, \
919             {removed} removes, {in_place} left in place"
920        );
921    }
922
923    #[test]
924    fn round_trips_over_a_long_random_walk() {
925        // A cheap deterministic PRNG: this is a test, and reproducibility beats entropy.
926        let mut seed = 0x243f_6a88_85a3_08d3u64;
927        let mut rand = move || {
928            seed ^= seed << 13;
929            seed ^= seed >> 7;
930            seed ^= seed << 17;
931            seed
932        };
933
934        let mut items: Vec<(u32, String, bool)> = Vec::new();
935        let mut next_key = 0u32;
936        let mut current = list(vec![]);
937
938        for _ in 0..400 {
939            match rand() % 4 {
940                0 => {
941                    next_key += 1;
942                    let at = if items.is_empty() {
943                        0
944                    } else {
945                        (rand() as usize) % (items.len() + 1)
946                    };
947                    items.insert(at, (next_key, format!("todo {next_key}"), false));
948                }
949                1 if !items.is_empty() => {
950                    let at = (rand() as usize) % items.len();
951                    items.remove(at);
952                }
953                2 if !items.is_empty() => {
954                    let at = (rand() as usize) % items.len();
955                    items[at].2 = !items[at].2;
956                }
957                3 if items.len() > 1 => {
958                    let from = (rand() as usize) % items.len();
959                    let to = (rand() as usize) % items.len();
960                    let item = items.remove(from);
961                    items.insert(to, item);
962                }
963                _ => {}
964            }
965
966            let next = list(
967                items
968                    .iter()
969                    .map(|(k, t, d)| li(&k.to_string(), t, *d))
970                    .collect(),
971            );
972            let ops = diff(&current, &next);
973            assert_eq!(apply(&current, &ops), next, "ops: {ops:#?}");
974            current = next;
975        }
976    }
977}