Skip to main content

azul_core/
diff.rs

1//! DOM Reconciliation Module
2//!
3//! This module provides the reconciliation algorithm that compares two DOM trees
4//! and generates lifecycle events. It uses stable keys and content hashing to
5//! identify moves vs. mounts/unmounts.
6//!
7//! The reconciliation strategy is:
8//! 1. **Stable Key Match:** If `.with_key()` is used, it's an absolute match (O(1)).
9//! 2. **CSS ID Match:** If no key, use the CSS ID as key.
10//! 3. **Structural Key Match:** nth-of-type-within-parent + parent's key (recursive).
11//! 4. **Hash Match (Content Match):** Check for identical `DomNodeHash`.
12//! 5. **Structural Hash Match:** For text nodes, match by structural hash (ignoring content).
13//! 6. **Fallback:** Anything not matched is a `Mount` (new) or `Unmount` (old leftovers).
14
15use alloc::{
16    collections::BTreeMap,
17    collections::VecDeque,
18    string::{String, ToString},
19    vec::Vec,
20};
21use core::hash::Hash;
22
23use azul_css::props::property::{CssPropertyType, RelayoutScope};
24
25use crate::{
26    dom::{DomId, DomNodeHash, DomNodeId, IdOrClass, NodeData, NodeType},
27    events::{
28        ComponentEventFilter, EventData, EventFilter, EventPhase, EventSource, EventType,
29        LifecycleEventData, LifecycleReason, SyntheticEvent,
30    },
31    geom::LogicalRect,
32    id::NodeId,
33    refany::RefAny,
34    styled_dom::{
35        ChangedCssProperty, NodeHierarchyItem, NodeHierarchyItemId, RestyleResult, StyledNodeState,
36    },
37    task::Instant,
38    OrderedMap,
39};
40
41// ============================================================================
42// NodeChangeSet — granular per-node change flags
43// ============================================================================
44
45/// Bit flags describing what changed about a node between old and new DOM.
46/// Multiple flags can be set simultaneously. Uses manual bit manipulation
47/// instead of bitflags crate to avoid adding a dependency.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub struct NodeChangeSet {
50    pub bits: u32,
51}
52
53impl NodeChangeSet {
54    // --- Changes that affect LAYOUT (need relayout + repaint) ---
55
56    /// Node type changed entirely (e.g., Text → Image).
57    pub const NODE_TYPE_CHANGED: u32 = 0b0000_0000_0000_0001;
58    /// Text content changed (for Text nodes).
59    pub const TEXT_CONTENT: u32 = 0b0000_0000_0000_0010;
60    /// CSS IDs or classes changed (may cause restyle → relayout).
61    pub const IDS_AND_CLASSES: u32 = 0b0000_0000_0000_0100;
62    /// Inline CSS properties changed that affect layout.
63    pub const INLINE_STYLE_LAYOUT: u32 = 0b0000_0000_0000_1000;
64    /// Children added, removed, or reordered.
65    pub const CHILDREN_CHANGED: u32 = 0b0000_0000_0001_0000;
66    /// Image source changed (may affect intrinsic size).
67    pub const IMAGE_CHANGED: u32 = 0b0000_0000_0010_0000;
68    /// Contenteditable flag changed.
69    pub const CONTENTEDITABLE: u32 = 0b0000_0000_0100_0000;
70    /// Tab index changed.
71    pub const TAB_INDEX: u32 = 0b0000_0000_1000_0000;
72
73    // --- Changes that affect PAINT only (no relayout needed) ---
74
75    /// Inline CSS properties changed that affect paint only.
76    pub const INLINE_STYLE_PAINT: u32 = 0b0000_0001_0000_0000;
77    /// Styled node state changed (hover, active, focus, etc.).
78    pub const STYLED_STATE: u32 = 0b0000_0010_0000_0000;
79
80    // --- Changes that affect NEITHER layout nor paint ---
81
82    /// Callbacks changed (new `RefAny`, different event handlers).
83    pub const CALLBACKS: u32 = 0b0000_0100_0000_0000;
84    /// Dataset changed.
85    pub const DATASET: u32 = 0b0000_1000_0000_0000;
86    /// Accessibility info changed.
87    pub const ACCESSIBILITY: u32 = 0b0001_0000_0000_0000;
88
89    // --- Composite masks ---
90
91    /// Any change that requires a layout pass.
92    pub const AFFECTS_LAYOUT: u32 = Self::NODE_TYPE_CHANGED
93        | Self::TEXT_CONTENT
94        | Self::IDS_AND_CLASSES
95        | Self::INLINE_STYLE_LAYOUT
96        | Self::CHILDREN_CHANGED
97        | Self::IMAGE_CHANGED
98        | Self::CONTENTEDITABLE;
99
100    /// Any change that requires a paint/display-list update (but not layout).
101    pub const AFFECTS_PAINT: u32 = Self::INLINE_STYLE_PAINT | Self::STYLED_STATE;
102
103    #[must_use]
104    pub const fn empty() -> Self {
105        Self { bits: 0 }
106    }
107
108    #[must_use]
109    pub const fn is_empty(&self) -> bool {
110        self.bits == 0
111    }
112
113    #[must_use]
114    pub const fn contains(&self, flag: u32) -> bool {
115        (self.bits & flag) == flag
116    }
117
118    #[must_use]
119    pub const fn intersects(&self, mask: u32) -> bool {
120        (self.bits & mask) != 0
121    }
122
123    pub const fn insert(&mut self, flag: u32) {
124        self.bits |= flag;
125    }
126
127    /// Returns true if no visual change occurred (only callbacks/dataset/a11y).
128    #[must_use]
129    pub const fn is_visually_unchanged(&self) -> bool {
130        !self.intersects(Self::AFFECTS_LAYOUT) && !self.intersects(Self::AFFECTS_PAINT)
131    }
132
133    /// Returns true if layout is needed.
134    #[must_use]
135    pub const fn needs_layout(&self) -> bool {
136        self.intersects(Self::AFFECTS_LAYOUT)
137    }
138
139    /// Returns true if paint is needed (but not necessarily layout).
140    #[must_use]
141    pub const fn needs_paint(&self) -> bool {
142        self.intersects(Self::AFFECTS_PAINT)
143    }
144}
145
146impl core::ops::BitOrAssign for NodeChangeSet {
147    fn bitor_assign(&mut self, rhs: Self) {
148        self.bits |= rhs.bits;
149    }
150}
151
152impl core::ops::BitOr for NodeChangeSet {
153    type Output = Self;
154    fn bitor(self, rhs: Self) -> Self {
155        Self {
156            bits: self.bits | rhs.bits,
157        }
158    }
159}
160
161/// Extended diff result that includes per-node change information.
162#[derive(Debug, Clone, Default)]
163pub struct ExtendedDiffResult {
164    /// Original diff result (lifecycle events + node moves).
165    pub diff: DiffResult,
166    /// Per-node change report for matched (moved) nodes.
167    /// Each entry: (`old_node_id`, `new_node_id`, `what_changed`).
168    /// Only contains entries for nodes that were matched.
169    pub node_changes: Vec<(NodeId, NodeId, NodeChangeSet)>,
170}
171
172/// Compare two matched `NodeData` instances field-by-field and return
173/// a `NodeChangeSet` describing what changed.
174#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
175#[must_use]
176pub fn compute_node_changes(
177    old_node: &NodeData,
178    new_node: &NodeData,
179    old_styled_state: Option<&StyledNodeState>,
180    new_styled_state: Option<&StyledNodeState>,
181) -> NodeChangeSet {
182    let mut changes = NodeChangeSet::empty();
183
184    // 1. Node type discriminant
185    if core::mem::discriminant(old_node.get_node_type())
186        != core::mem::discriminant(new_node.get_node_type())
187    {
188        changes.insert(NodeChangeSet::NODE_TYPE_CHANGED);
189        return changes; // everything else is irrelevant
190    }
191
192    // 2. Content-specific comparison (same discriminant)
193    match (old_node.get_node_type(), new_node.get_node_type()) {
194        (NodeType::Text(old_text), NodeType::Text(new_text)) => {
195            if old_text.as_str() != new_text.as_str() {
196                changes.insert(NodeChangeSet::TEXT_CONTENT);
197            }
198        }
199        (NodeType::Image(old_img), NodeType::Image(new_img)) => {
200            // Use Hash-based comparison (pointer identity for decoded images,
201            // callback identity for callback images)
202            use core::hash::Hasher;
203            let hash_img = |img: &crate::resources::ImageRef| -> u64 {
204                let mut h = crate::hash::DefaultHasher::new();
205                img.hash(&mut h);
206                h.finish()
207            };
208            if hash_img(old_img) != hash_img(new_img) {
209                changes.insert(NodeChangeSet::IMAGE_CHANGED);
210            }
211        }
212        _ => {} // Same non-content type → no content change
213    }
214
215    // 3. IDs and classes (now stored in attributes as AttributeType::Id/Class)
216    {
217        use crate::dom::AttributeType;
218        let old_ids_classes: Vec<_> = old_node
219            .attributes()
220            .as_ref()
221            .iter()
222            .filter(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
223            .collect();
224        let new_ids_classes: Vec<_> = new_node
225            .attributes()
226            .as_ref()
227            .iter()
228            .filter(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
229            .collect();
230        if old_ids_classes != new_ids_classes {
231            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
232        }
233    }
234
235    // 4. Inline CSS properties — classify into layout-affecting vs paint-only.
236    // After the inline-vs-component unification, inline CSS is stored as a `Css`
237    // with rule blocks; iterate it via the `(property, conditions)` flat view to
238    // keep the per-property compare semantics this code was written for.
239    if old_node.style != new_node.style {
240        let mut has_layout = false;
241        let mut has_paint = false;
242
243        // Classify a changed/added/removed property into the layout vs paint bucket.
244        #[allow(clippy::items_after_statements)]
245        fn mark(prop_type: CssPropertyType, has_layout: &mut bool, has_paint: &mut bool) {
246            if prop_type.relayout_scope(true) == RelayoutScope::None {
247                *has_paint = true;
248            } else {
249                *has_layout = true;
250            }
251        }
252
253        // AUDIT: key the diff by (prop_type, conditions), NOT prop_type alone.
254        // A node can carry the same property under different conditions (e.g.
255        // `color: red` and `color: blue` scoped to `:hover`); keying by
256        // prop_type collapsed them into one map slot, so a change to one
257        // conditional variant could be silently dropped. Match each new
258        // property against an old entry with the SAME prop_type AND the same
259        // conditions, and mark any old entry left unmatched as removed.
260        let old_props: Vec<(CssPropertyType, _, _)> = old_node
261            .style
262            .iter_inline_properties()
263            .map(|(prop, conds)| (prop.get_type(), prop, conds))
264            .collect();
265        let mut old_matched = vec![false; old_props.len()];
266
267        for (prop, conds) in new_node.style.iter_inline_properties() {
268            let prop_type = prop.get_type();
269            // Find an as-yet-unmatched old entry with the same (type, conditions).
270            let mut found_unchanged = false;
271            for (i, (old_type, old_prop, old_conds)) in old_props.iter().enumerate() {
272                if old_matched[i]
273                    || *old_type != prop_type
274                    || old_conds.as_slice() != conds.as_slice()
275                {
276                    continue;
277                }
278                old_matched[i] = true;
279                if *old_prop == prop {
280                    found_unchanged = true;
281                }
282                break;
283            }
284            // Unchanged only when we matched an old (type, conditions) slot whose
285            // value is identical; otherwise the property was added or changed.
286            if !found_unchanged {
287                mark(prop_type, &mut has_layout, &mut has_paint);
288            }
289        }
290
291        // Check for removed properties (old (type, conditions) slots never matched)
292        for (i, (old_type, _, _)) in old_props.iter().enumerate() {
293            if !old_matched[i] {
294                mark(*old_type, &mut has_layout, &mut has_paint);
295            }
296        }
297
298        if has_layout {
299            changes.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
300        }
301        if has_paint {
302            changes.insert(NodeChangeSet::INLINE_STYLE_PAINT);
303        }
304    }
305
306    // 5. Callbacks
307    {
308        let old_cbs = old_node.callbacks.as_ref();
309        let new_cbs = new_node.callbacks.as_ref();
310        if old_cbs.len() == new_cbs.len() {
311            for (o, n) in old_cbs.iter().zip(new_cbs.iter()) {
312                if o.event != n.event || o.callback != n.callback {
313                    changes.insert(NodeChangeSet::CALLBACKS);
314                    break;
315                }
316            }
317        } else {
318            changes.insert(NodeChangeSet::CALLBACKS);
319        }
320    }
321
322    // 6. Dataset
323    if old_node.get_dataset() != new_node.get_dataset() {
324        changes.insert(NodeChangeSet::DATASET);
325    }
326
327    // 7. Contenteditable
328    if old_node.is_contenteditable() != new_node.is_contenteditable() {
329        changes.insert(NodeChangeSet::CONTENTEDITABLE);
330    }
331
332    // 8. Tab index
333    if old_node.get_tab_index() != new_node.get_tab_index() {
334        changes.insert(NodeChangeSet::TAB_INDEX);
335    }
336
337    // 9. Styled node state (hover, active, focused, etc.)
338    if old_styled_state != new_styled_state {
339        changes.insert(NodeChangeSet::STYLED_STATE);
340    }
341
342    changes
343}
344
345/// Calculate the reconciliation key for a node using the priority hierarchy:
346/// 1. Explicit key (set via `.with_key()`)
347/// 2. CSS ID (set via `.with_id("my-id")`)
348/// 3. Structural key: nth-of-type-within-parent + parent's reconciliation key
349///
350/// The structural key prevents incorrect matching when nodes are inserted
351/// before existing nodes (e.g., prepending items to a list) and allows
352/// keyless nodes to be matched across frames when their logical position
353/// and type are stable (even if content changed — which then fires an
354/// `Update` lifecycle event, see `reconcile_dom`).
355///
356/// When `hierarchy` is empty (or this node has no entry), the structural
357/// key degrades to `discriminant(node_type) + classes` — parent/nth-of-type
358/// context simply drops out. This lets callers that don't track hierarchy
359/// (tests, flat-DOM scenarios) still benefit from explicit-key and CSS-ID
360/// matching without divergent behavior.
361#[must_use]
362pub fn calculate_reconciliation_key(
363    node_data: &[NodeData],
364    hierarchy: &[NodeHierarchyItem],
365    node_id: NodeId,
366) -> u64 {
367    use core::hash::Hasher;
368
369    let n = node_data.len();
370
371    // Terminal (parent-independent) key for a node: Priority 1 explicit key,
372    // else Priority 2 CSS ID, else `None` (structural — needs the parent chain).
373    let terminal_key = |nid: NodeId| -> Option<u64> {
374        let node = &node_data[nid.index()];
375        // Priority 1: Explicit key
376        if let Some(key) = node.get_key() {
377            return Some(key);
378        }
379        // Priority 2: CSS ID
380        for attr in node.attributes().as_ref() {
381            if let Some(id) = attr.as_id() {
382                let mut hasher = crate::hash::DefaultHasher::new();
383                id.hash(&mut hasher);
384                return Some(hasher.finish());
385            }
386        }
387        None
388    };
389
390    // Fast path: the node itself has an explicit key or CSS ID.
391    if let Some(key) = terminal_key(node_id) {
392        return key;
393    }
394
395    // Priority 3: structural key, computed ITERATIVELY up the parent chain.
396    //
397    // AUDIT: the previous implementation recursed once per ancestor with no
398    // depth cap and no cycle guard, so a deep DOM overflowed the stack and a
399    // corrupt (cyclic) hierarchy recursed forever — and `precompute_*` calls
400    // this once per node. Walk upward instead, bounded by the node count.
401    //
402    // Collect the structural chain from `node_id` upward. The walk stops at:
403    //   - the root (a node with no parent) — structural base is just
404    //     `discriminant + classes`,
405    //   - a terminal (explicit-key / CSS-ID) ancestor, whose key seeds the fold, or
406    //   - `n` iterations (a valid parent chain is at most `n` long, so exceeding
407    //     that means the hierarchy is cyclic/corrupt — stop).
408    let mut chain: Vec<NodeId> = Vec::new();
409    let mut seed_parent_key: Option<u64> = None;
410    let mut cur = node_id;
411    for _ in 0..n {
412        if cur.index() >= n {
413            break;
414        }
415        chain.push(cur);
416        match hierarchy
417            .get(cur.index())
418            .and_then(NodeHierarchyItem::parent_id)
419        {
420            None => break,
421            Some(parent) => {
422                if let Some(k) = terminal_key(parent) {
423                    seed_parent_key = Some(k);
424                    break;
425                }
426                cur = parent;
427            }
428        }
429    }
430
431    // Fold from the topmost ancestor down to `node_id`. `parent_key` threads the
432    // accumulated key of the level above (identical to the old recursion, just
433    // unrolled bottom-up).
434    let mut parent_key: Option<u64> = seed_parent_key;
435    for &nid in chain.iter().rev() {
436        let node = &node_data[nid.index()];
437        let mut hasher = crate::hash::DefaultHasher::new();
438
439        core::mem::discriminant(node.get_node_type()).hash(&mut hasher);
440        for attr in node.attributes().as_ref() {
441            if let Some(class) = attr.as_class() {
442                class.hash(&mut hasher);
443            }
444        }
445
446        if let Some(parent_id) = hierarchy
447            .get(nid.index())
448            .and_then(NodeHierarchyItem::parent_id)
449        {
450            // nth-of-type: count same-discriminant siblings before `nid`.
451            let mut sibling_index: usize = 0;
452            let mut current = hierarchy
453                .get(parent_id.index())
454                .and_then(|h| h.first_child_id(parent_id));
455            while let Some(sibling_id) = current {
456                if sibling_id == nid {
457                    break;
458                }
459                let sibling = &node_data[sibling_id.index()];
460                if core::mem::discriminant(sibling.get_node_type())
461                    == core::mem::discriminant(node.get_node_type())
462                {
463                    sibling_index += 1;
464                }
465                current = hierarchy
466                    .get(sibling_id.index())
467                    .and_then(NodeHierarchyItem::next_sibling_id);
468            }
469
470            sibling_index.hash(&mut hasher);
471            parent_key.unwrap_or(0).hash(&mut hasher);
472        }
473
474        parent_key = Some(hasher.finish());
475    }
476
477    parent_key.unwrap_or(0)
478}
479
480/// Precompute reconciliation keys for every node in a DOM tree.
481///
482/// Called once per side (old/new) at the start of `reconcile_dom`. Returns a
483/// vector indexed by node index (`keys[node_id.index()]`) so lookup during
484/// reconciliation is O(1).
485#[must_use]
486pub fn precompute_reconciliation_keys(
487    node_data: &[NodeData],
488    hierarchy: &[NodeHierarchyItem],
489) -> Vec<u64> {
490    (0..node_data.len())
491        .map(|idx| calculate_reconciliation_key(node_data, hierarchy, NodeId::new(idx)))
492        .collect()
493}
494
495/// Represents a mapping between a node in the old DOM and the new DOM.
496#[derive(Debug, Clone, Copy)]
497pub struct NodeMove {
498    /// The `NodeId` in the old DOM array
499    pub old_node_id: NodeId,
500    /// The `NodeId` in the new DOM array
501    pub new_node_id: NodeId,
502}
503
504/// The result of a DOM diff, containing lifecycle events and node mappings.
505#[derive(Debug, Clone, Default)]
506pub struct DiffResult {
507    /// Lifecycle events generated by the diff (Mount, Unmount, Resize, Update)
508    pub events: Vec<SyntheticEvent>,
509    /// Maps Old `NodeId` -> New `NodeId` for state migration (focus, scroll, etc.)
510    pub node_moves: Vec<NodeMove>,
511}
512
513/// Per-node hash of the node's own content PLUS its entire subtree, children
514/// folded in document order. Two equal values mean the subtrees are
515/// content-identical for reconciliation purposes — the strong-identity tier
516/// `reconcile_dom` uses before falling back to positional matching.
517///
518/// The arena is depth-first pre-order (a parent's index is always lower than
519/// its children's), so one REVERSE walk sees every child before its parent.
520fn compute_subtree_hashes(node_data: &[NodeData], hierarchy: &[NodeHierarchyItem]) -> Vec<u64> {
521    use core::hash::{Hash, Hasher};
522    let mut hashes = vec![0u64; node_data.len()];
523    for idx in (0..node_data.len()).rev() {
524        let mut h = crate::hash::DefaultHasher::new();
525        node_data[idx].calculate_node_data_hash().hash(&mut h);
526        let mut child = hierarchy
527            .get(idx)
528            .and_then(|item| item.first_child_id(NodeId::new(idx)));
529        while let Some(c) = child {
530            if c.index() >= hashes.len() {
531                break;
532            }
533            hashes[c.index()].hash(&mut h);
534            child = hierarchy
535                .get(c.index())
536                .and_then(NodeHierarchyItem::next_sibling_id);
537        }
538        hashes[idx] = h.finish();
539    }
540    hashes
541}
542
543/// Calculates the difference between two DOM frames and generates lifecycle events.
544///
545/// This is the main entry point for DOM reconciliation. It compares the old and new
546/// DOM trees and produces:
547/// - Mount events for new nodes
548/// - Unmount events for removed nodes
549/// - Resize events for nodes whose bounds changed
550/// - Update events for nodes whose logical position is stable but content changed
551///
552/// # Matching priority
553/// For every node, the reconciliation key (`calculate_reconciliation_key`) encodes
554/// Priority 1 (`.with_key()`), Priority 2 (CSS ID), and Priority 3 (structural key:
555/// nth-of-type + parent key). The tiers are then tried in order:
556///
557/// 1. **Reconciliation key** — matches logical identity, may fire Update on content change.
558/// 2. **Content hash** — exact match including content; catches pure reorders of anonymous nodes.
559/// 3. **Structural hash** — matches node type + attrs ignoring text content; for text-edit cases.
560///
561/// # Arguments
562/// * `old_node_data` / `new_node_data` - Per-node data for each frame
563/// * `old_hierarchy` / `new_hierarchy` - Parent/sibling pointers. Pass `&[]` if unavailable;
564///   the structural-key branch of the reconciliation key degrades gracefully.
565/// * `old_layout` / `new_layout` - Layout bounds used to detect Resize events
566/// * `dom_id` - The DOM identifier
567/// * `timestamp` - Current timestamp for events
568#[allow(clippy::needless_pass_by_value)] // owned azul value taken by value (public API / ownership-transfer convention)
569#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
570#[must_use]
571pub fn reconcile_dom(
572    old_node_data: &[NodeData],
573    new_node_data: &[NodeData],
574    old_hierarchy: &[NodeHierarchyItem],
575    new_hierarchy: &[NodeHierarchyItem],
576    old_layout: &OrderedMap<NodeId, LogicalRect>,
577    new_layout: &OrderedMap<NodeId, LogicalRect>,
578    dom_id: DomId,
579    timestamp: Instant,
580) -> DiffResult {
581    // Helper: pop the first non-consumed NodeId from a queue.
582    fn pop_first_unconsumed(queue: &mut VecDeque<NodeId>, consumed: &[bool]) -> Option<NodeId> {
583        while let Some(&old_id) = queue.front() {
584            queue.pop_front();
585            if !consumed[old_id.index()] {
586                return Some(old_id);
587            }
588        }
589        None
590    }
591
592    let mut result = DiffResult::default();
593
594    // --- STEP 1: INDEX THE OLD DOM ---
595    //
596    // Three tiers, in priority order:
597    //   Tier 1: reconciliation key (.with_key() / CSS ID / structural key)
598    //   Tier 2: content hash (exact node_data hash — matches pure reorders)
599    //   Tier 3: structural hash (discriminant + attrs, ignores text — matches text edits)
600    //
601    // Each tier is keyed with a `VecDeque<NodeId>` because all three can legitimately
602    // collide (two sibling divs produce the same structural key, two identical nodes
603    // produce the same content hash, etc.); we consume in document order on match.
604
605    let old_rec_keys = precompute_reconciliation_keys(old_node_data, old_hierarchy);
606    // AUDIT: precompute NEW keys too so the Tier-2/Tier-3 keyless tiers can be
607    // gated on parent-key agreement (see STEP 2). Also lets Tier 1 look the key
608    // up instead of recomputing it per node.
609    let new_rec_keys = precompute_reconciliation_keys(new_node_data, new_hierarchy);
610
611    // Reconciliation key of a node's PARENT (`None` for a root or when the
612    // hierarchy is unavailable). Used to keep keyless matches from migrating
613    // focus/scroll/dataset state across different parents.
614    let old_parent_key = |old_id: NodeId| -> Option<u64> {
615        old_hierarchy
616            .get(old_id.index())
617            .and_then(NodeHierarchyItem::parent_id)
618            .map(|p| old_rec_keys[p.index()])
619    };
620
621    let mut old_by_rec_key: OrderedMap<u64, VecDeque<NodeId>> = OrderedMap::default();
622    let mut old_hashed: OrderedMap<DomNodeHash, VecDeque<NodeId>> = OrderedMap::default();
623    let mut old_structural: OrderedMap<DomNodeHash, VecDeque<NodeId>> = OrderedMap::default();
624    let mut old_nodes_consumed = vec![false; old_node_data.len()];
625
626    for (idx, node) in old_node_data.iter().enumerate() {
627        let id = NodeId::new(idx);
628        old_by_rec_key
629            .entry(old_rec_keys[idx])
630            .or_default()
631            .push_back(id);
632
633        let hash = node.calculate_node_data_hash();
634        old_hashed.entry(hash).or_default().push_back(id);
635
636        let structural_hash = node.calculate_structural_hash();
637        old_structural
638            .entry(structural_hash)
639            .or_default()
640            .push_back(id);
641    }
642
643    // --- STEP 2: CLAIM MATCHES, STRONG IDENTITY BEFORE POSITIONAL ---
644    //
645    // The old single loop ran every tier per new node, in new-document order.
646    // That let a PREPENDED subtree steal identity: the auto "structural"
647    // reconciliation key hashes an nth-of-type sibling index, so prepending a
648    // banner shifts every following sibling's key by one and Tier 1 pairs
649    // banner↔page0, page0↔page1, … — and (DomId, NodeId)-keyed state (text
650    // overlay, focus, selections) follows the WRONG element. Shallow
651    // content-hash tiers cannot save it either: a wrapper div's own hash
652    // ignores the child text that actually distinguishes it.
653    //
654    // So matching now runs in PASSES over all new nodes, strongest evidence
655    // first, positional evidence last:
656    //   A1: terminal identity (explicit `.with_key()` / CSS ID)
657    //   A2: exact SUBTREE hash (node + entire subtree content-identical) —
658    //       deliberately NOT parent-gated: re-pagination moves a paragraph's
659    //       whole subtree under a different page container, and following it
660    //       there is the point.
661    //   B1: positional structural key (the old Tier 1 fallback)
662    //   B2: shallow content hash   (parent-gated, as before)
663    //   B3: shallow structural hash (parent-gated, as before — text edits)
664    let old_subtree_hashes = compute_subtree_hashes(old_node_data, old_hierarchy);
665    let new_subtree_hashes = compute_subtree_hashes(new_node_data, new_hierarchy);
666    let mut old_by_subtree: OrderedMap<u64, VecDeque<NodeId>> = OrderedMap::default();
667    for (idx, h) in old_subtree_hashes.iter().enumerate() {
668        old_by_subtree
669            .entry(*h)
670            .or_default()
671            .push_back(NodeId::new(idx));
672    }
673
674    let has_terminal_identity = |node: &NodeData| -> bool {
675        node.get_key().is_some()
676            || node
677                .attributes()
678                .as_ref()
679                .iter()
680                .any(|attr| attr.as_id().is_some())
681    };
682
683    let n_new = new_node_data.len();
684    let mut matched: Vec<Option<NodeId>> = vec![None; n_new];
685    let mut matched_by_rec_key: Vec<bool> = vec![false; n_new];
686
687    // Pass A1: terminal identity (explicit key / CSS ID). Their reconciliation
688    // key IS the terminal key, so the existing queue serves them.
689    for (new_idx, new_node) in new_node_data.iter().enumerate() {
690        if !has_terminal_identity(new_node) {
691            continue;
692        }
693        if let Some(queue) = old_by_rec_key.get_mut(&new_rec_keys[new_idx]) {
694            if let Some(old_id) = pop_first_unconsumed(queue, &old_nodes_consumed) {
695                old_nodes_consumed[old_id.index()] = true;
696                matched[new_idx] = Some(old_id);
697                matched_by_rec_key[new_idx] = true;
698            }
699        }
700    }
701
702    // Pass A2: exact subtree identity. An explicit `.with_key()` that missed
703    // A1 stays unmatched (Mount) — a key is an intentional identity marker.
704    // Identical twins (equal subtrees) consume in document order, which is
705    // the same positional tie-break they got before.
706    //
707    // Cross-parent moves are ALLOWED between anonymous parents (that is the
708    // point: re-pagination shifts a paragraph's whole subtree under a
709    // different page container and state must follow it) — but NOT across a
710    // parent whose TERMINAL identity (explicit key / CSS ID) changed:
711    // `#left → #right` is the author saying "a different container", and the
712    // parent-key-gate test pins that a leaf must not migrate across it.
713    let terminal_key_of = |node: &NodeData| -> Option<u64> {
714        use core::hash::{Hash, Hasher};
715        if let Some(key) = node.get_key() {
716            return Some(key);
717        }
718        for attr in node.attributes().as_ref() {
719            if let Some(id) = attr.as_id() {
720                let mut hasher = crate::hash::DefaultHasher::new();
721                id.hash(&mut hasher);
722                return Some(hasher.finish());
723            }
724        }
725        None
726    };
727    let old_parent_terminal = |old_id: NodeId| -> Option<u64> {
728        old_hierarchy
729            .get(old_id.index())
730            .and_then(NodeHierarchyItem::parent_id)
731            .and_then(|p| terminal_key_of(&old_node_data[p.index()]))
732    };
733    for new_idx in 0..n_new {
734        if matched[new_idx].is_some() || new_node_data[new_idx].get_key().is_some() {
735            continue;
736        }
737        let new_parent_terminal: Option<u64> = new_hierarchy
738            .get(new_idx)
739            .and_then(NodeHierarchyItem::parent_id)
740            .and_then(|p| terminal_key_of(&new_node_data[p.index()]));
741        if let Some(queue) = old_by_subtree.get_mut(&new_subtree_hashes[new_idx]) {
742            if let Some(pos) = queue.iter().position(|&old_id| {
743                !old_nodes_consumed[old_id.index()]
744                    && old_parent_terminal(old_id) == new_parent_terminal
745            }) {
746                if let Some(old_id) = queue.remove(pos) {
747                    old_nodes_consumed[old_id.index()] = true;
748                    matched[new_idx] = Some(old_id);
749                }
750            }
751        }
752    }
753
754    // Pass B1: positional structural key — the old Tier 1 for keyless nodes,
755    // now running only for what strong evidence left over.
756    for (new_idx, new_node) in new_node_data.iter().enumerate() {
757        if matched[new_idx].is_some() || new_node.get_key().is_some() {
758            continue;
759        }
760        if let Some(queue) = old_by_rec_key.get_mut(&new_rec_keys[new_idx]) {
761            if let Some(old_id) = pop_first_unconsumed(queue, &old_nodes_consumed) {
762                old_nodes_consumed[old_id.index()] = true;
763                matched[new_idx] = Some(old_id);
764                matched_by_rec_key[new_idx] = true;
765            }
766        }
767    }
768
769    // Passes B2/B3: shallow content / structural hash.
770    for (new_idx, new_node) in new_node_data.iter().enumerate() {
771        if matched[new_idx].is_some() || new_node.get_key().is_some() {
772            continue;
773        }
774        // AUDIT: parent-key of the new node. The keyless shallow tiers are
775        // only allowed to claim an old node whose parent's reconciliation key
776        // agrees — otherwise two structurally-identical nodes under DIFFERENT
777        // parents would match and migrate focus/scroll/dataset state to an
778        // unrelated subtree. When either hierarchy is unavailable this is
779        // `None` on both sides, so the gate is a no-op.
780        let new_parent_key: Option<u64> = new_hierarchy
781            .get(new_idx)
782            .and_then(NodeHierarchyItem::parent_id)
783            .map(|p| new_rec_keys[p.index()]);
784
785        // B2: Content hash (exact match — catches pure reorders)
786        let hash = new_node.calculate_node_data_hash();
787        if let Some(queue) = old_hashed.get_mut(&hash) {
788            if let Some(pos) = queue.iter().position(|&old_id| {
789                !old_nodes_consumed[old_id.index()] && old_parent_key(old_id) == new_parent_key
790            }) {
791                if let Some(old_id) = queue.remove(pos) {
792                    old_nodes_consumed[old_id.index()] = true;
793                    matched[new_idx] = Some(old_id);
794                    continue;
795                }
796            }
797        }
798
799        // B3: Structural hash (text-node fallback — ignores text content)
800        let structural_hash = new_node.calculate_structural_hash();
801        if let Some(queue) = old_structural.get_mut(&structural_hash) {
802            if let Some(pos) = queue.iter().position(|&old_id| {
803                !old_nodes_consumed[old_id.index()] && old_parent_key(old_id) == new_parent_key
804            }) {
805                if let Some(old_id) = queue.remove(pos) {
806                    old_nodes_consumed[old_id.index()] = true;
807                    matched[new_idx] = Some(old_id);
808                }
809            }
810        }
811    }
812
813    // --- STEP 3: PROCESS MATCHES / MOUNTS, in new-document order ---
814
815    for (new_idx, new_node) in new_node_data.iter().enumerate() {
816        let new_id = NodeId::new(new_idx);
817        let matched_old_id = matched[new_idx];
818        let matched_by_rec_key = matched_by_rec_key[new_idx];
819
820        if let Some(old_id) = matched_old_id {
821            // FOUND A MATCH (It might be at a different index, but it's the "same" node)
822
823            result.node_moves.push(NodeMove {
824                old_node_id: old_id,
825                new_node_id: new_id,
826            });
827
828            // Check for Resize
829            let old_rect = old_layout
830                .get(&old_id)
831                .copied()
832                .unwrap_or(LogicalRect::zero());
833            let new_rect = new_layout
834                .get(&new_id)
835                .copied()
836                .unwrap_or(LogicalRect::zero());
837
838            if old_rect.size != new_rect.size {
839                // Fire Resize Event
840                if has_resize_callback(new_node) {
841                    result.events.push(create_lifecycle_event(
842                        EventType::Resize,
843                        new_id,
844                        dom_id,
845                        &timestamp,
846                        LifecycleEventData {
847                            reason: LifecycleReason::Resize,
848                            previous_bounds: Some(old_rect),
849                            current_bounds: new_rect,
850                        },
851                    ));
852                }
853            }
854
855            // Fire Update when the node was matched by logical identity (reconciliation
856            // key: explicit .with_key(), CSS ID, or structural key) but its content hash
857            // differs. Tier-2/Tier-3 matches by definition don't carry an Update — a
858            // content-hash match is content-identical, and a structural-hash match is
859            // a text edit handled by cursor/text reconciliation elsewhere.
860            if matched_by_rec_key {
861                let old_hash = old_node_data[old_id.index()].calculate_node_data_hash();
862                let new_hash = new_node.calculate_node_data_hash();
863
864                if old_hash != new_hash && has_update_callback(new_node) {
865                    result.events.push(create_lifecycle_event(
866                        EventType::Update,
867                        new_id,
868                        dom_id,
869                        &timestamp,
870                        LifecycleEventData {
871                            reason: LifecycleReason::Update,
872                            previous_bounds: Some(old_rect),
873                            current_bounds: new_rect,
874                        },
875                    ));
876                }
877            }
878        } else {
879            // NO MATCH FOUND -> MOUNT (New Node)
880            if has_mount_callback(new_node) {
881                let bounds = new_layout
882                    .get(&new_id)
883                    .copied()
884                    .unwrap_or(LogicalRect::zero());
885                result.events.push(create_lifecycle_event(
886                    EventType::Mount,
887                    new_id,
888                    dom_id,
889                    &timestamp,
890                    LifecycleEventData {
891                        reason: LifecycleReason::InitialMount,
892                        previous_bounds: None,
893                        current_bounds: bounds,
894                    },
895                ));
896            }
897        }
898    }
899
900    // --- STEP 4: CLEANUP (UNMOUNTS) ---
901    // Any old node that wasn't claimed is effectively destroyed.
902
903    for (old_idx, consumed) in old_nodes_consumed.iter().enumerate() {
904        if !consumed {
905            let old_id = NodeId::new(old_idx);
906            let old_node = &old_node_data[old_idx];
907
908            if has_unmount_callback(old_node) {
909                let bounds = old_layout
910                    .get(&old_id)
911                    .copied()
912                    .unwrap_or(LogicalRect::zero());
913                result.events.push(create_lifecycle_event(
914                    EventType::Unmount,
915                    old_id,
916                    dom_id,
917                    &timestamp,
918                    LifecycleEventData {
919                        reason: LifecycleReason::Unmount,
920                        previous_bounds: Some(bounds),
921                        current_bounds: LogicalRect::zero(),
922                    },
923                ));
924            }
925        }
926    }
927
928    result
929}
930
931/// Creates a lifecycle event with all necessary fields.
932fn create_lifecycle_event(
933    event_type: EventType,
934    node_id: NodeId,
935    dom_id: DomId,
936    timestamp: &Instant,
937    data: LifecycleEventData,
938) -> SyntheticEvent {
939    let dom_node_id = DomNodeId {
940        dom: dom_id,
941        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
942    };
943    SyntheticEvent {
944        event_type,
945        source: EventSource::Lifecycle,
946        phase: EventPhase::Target,
947        target: dom_node_id,
948        current_target: dom_node_id,
949        timestamp: timestamp.clone(),
950        data: EventData::Lifecycle(data),
951        stopped: false,
952        stopped_immediate: false,
953        prevented_default: false,
954        at_target_only: false,
955    }
956}
957
958/// The event a `<transient-window>` receives when the USER closed it — an
959/// outside click, or Escape — as opposed to the app flipping `open`.
960///
961/// Built here, next to the other lifecycle events, so it carries the same
962/// `EventSource::Lifecycle` / `EventPhase::Target` shape the dispatcher
963/// expects for a `ComponentEventFilter`. `bounds` is the popup's anchor
964/// rect in the parent, the closest thing to "where it was".
965#[must_use]
966pub fn create_dismiss_event(
967    node_id: NodeId,
968    dom_id: DomId,
969    timestamp: &Instant,
970    bounds: LogicalRect,
971) -> SyntheticEvent {
972    create_lifecycle_event(
973        EventType::Dismiss,
974        node_id,
975        dom_id,
976        timestamp,
977        LifecycleEventData {
978            reason: LifecycleReason::Dismiss,
979            previous_bounds: None,
980            current_bounds: bounds,
981        },
982    )
983}
984
985/// The lifecycle event a `<transient-window>` gets on a tear-off or a dock.
986///
987/// `torn == true`: torn off its anchor (`bounds` = the toplevel's rect in the
988/// parent). `torn == false`: docked back (`bounds` = the anchor it docked onto).
989#[must_use]
990pub fn create_tearoff_event(
991    node_id: NodeId,
992    dom_id: DomId,
993    timestamp: &Instant,
994    torn: bool,
995    bounds: LogicalRect,
996) -> SyntheticEvent {
997    let (ty, reason) = if torn {
998        (EventType::TearOff, LifecycleReason::TearOff)
999    } else {
1000        (EventType::Dock, LifecycleReason::Dock)
1001    };
1002    create_lifecycle_event(
1003        ty,
1004        node_id,
1005        dom_id,
1006        timestamp,
1007        LifecycleEventData {
1008            reason,
1009            previous_bounds: None,
1010            current_bounds: bounds,
1011        },
1012    )
1013}
1014
1015/// Check if the node has an `AfterMount` callback registered.
1016fn has_mount_callback(node: &NodeData) -> bool {
1017    node.get_callbacks().iter().any(|cb| {
1018        matches!(
1019            cb.event,
1020            EventFilter::Component(ComponentEventFilter::AfterMount)
1021        )
1022    })
1023}
1024
1025/// Check if the node has a `BeforeUnmount` callback registered.
1026fn has_unmount_callback(node: &NodeData) -> bool {
1027    node.get_callbacks().iter().any(|cb| {
1028        matches!(
1029            cb.event,
1030            EventFilter::Component(ComponentEventFilter::BeforeUnmount)
1031        )
1032    })
1033}
1034
1035/// Check if the node has a `NodeResized` callback registered.
1036fn has_resize_callback(node: &NodeData) -> bool {
1037    node.get_callbacks().iter().any(|cb| {
1038        matches!(
1039            cb.event,
1040            EventFilter::Component(ComponentEventFilter::NodeResized)
1041        )
1042    })
1043}
1044
1045/// Check if the node has any lifecycle callback that would respond to updates.
1046fn has_update_callback(node: &NodeData) -> bool {
1047    node.get_callbacks().iter().any(|cb| {
1048        matches!(
1049            cb.event,
1050            EventFilter::Component(ComponentEventFilter::Updated)
1051        )
1052    })
1053}
1054
1055/// Migrate state (focus, scroll, etc.) from old node IDs to new node IDs.
1056///
1057/// This function should be called after reconciliation to update any state
1058/// that references old `NodeIds` to use the new `NodeIds`.
1059///
1060/// # Example
1061/// ```rust,ignore
1062/// let diff = reconcile_dom(...);
1063/// let migration_map = create_migration_map(&diff.node_moves);
1064///
1065/// // Migrate focus
1066/// if let Some(current_focus) = focus_manager.focused_node {
1067///     if let Some(&new_id) = migration_map.get(&current_focus) {
1068///         focus_manager.focused_node = Some(new_id);
1069///     } else {
1070///         // Focused node was unmounted, clear focus
1071///         focus_manager.focused_node = None;
1072///     }
1073/// }
1074/// ```
1075#[must_use]
1076pub fn create_migration_map(node_moves: &[NodeMove]) -> OrderedMap<NodeId, NodeId> {
1077    let mut map = OrderedMap::default();
1078    for m in node_moves {
1079        map.insert(m.old_node_id, m.new_node_id);
1080    }
1081    map
1082}
1083
1084/// Suppression tag for the image-churn lint, honored from `AZ_SUPPRESS`.
1085pub const IMAGE_CHURN_SUPPRESS_TAG: &str = "image_churn";
1086
1087/// Re-initialisations per second above which an image node is churning.
1088///
1089/// A widget legitimately rebuilds its image node with a placeholder now and
1090/// then — the first build after mount has no frame yet. Doing it dozens of
1091/// times a second means a LIVE image is being discarded and re-awaited on every
1092/// frame, which is a bug in how the node is built, not in the content.
1093const IMAGE_CHURN_PER_SEC: u32 = 10;
1094
1095/// The framework notices, by itself, when an image node re-initialises at frame
1096/// rate — and says what is almost always wrong.
1097///
1098/// The symptom is a video or capture node that flickers: it holds a real frame,
1099/// the DOM rebuilds, the fresh node carries only a placeholder, and the live
1100/// image is thrown away until the next frame arrives 16-33ms later. On a
1101/// resizing window, which rebuilds continuously, that is a continuous flash.
1102///
1103/// The cause is almost always a missing DATASET + merge callback. Without one
1104/// the reconciler cannot tell that the rebuilt node is the same widget, so it
1105/// has nothing to carry forward — see `transfer_states`, which does carry the
1106/// previous frame when a merge callback exists.
1107///
1108/// Detection lives HERE, in the reconciler, because neither DOM shows it alone:
1109/// the old build has a frame, the new build has a placeholder, and only the
1110/// pair reveals the churn. No user code has to opt in.
1111/// Per-node churn bookkeeping: `(count, window_start, warned)`.
1112///
1113/// Shared with the tests so the detector can be asserted on directly, instead
1114/// of only through a message on stderr that nothing can observe.
1115#[cfg(feature = "std")]
1116type ImageChurnMap = BTreeMap<usize, (u32, std::time::Instant, bool)>;
1117
1118#[cfg(feature = "std")]
1119fn image_churn_state() -> &'static std::sync::Mutex<ImageChurnMap> {
1120    use std::sync::{Mutex, OnceLock};
1121    static CHURN: OnceLock<Mutex<ImageChurnMap>> = OnceLock::new();
1122    CHURN.get_or_init(|| Mutex::new(ImageChurnMap::new()))
1123}
1124
1125/// How many times this node has re-initialised inside the current window.
1126#[cfg(all(feature = "std", test))]
1127pub(crate) fn image_churn_count(node_index: usize) -> u32 {
1128    image_churn_state()
1129        .lock()
1130        .ok()
1131        .and_then(|m| m.get(&node_index).map(|e| e.0))
1132        .unwrap_or(0)
1133}
1134
1135#[cfg(feature = "std")]
1136fn note_image_reinitialised(node_index: usize, carried: bool) {
1137    use std::{
1138        collections::BTreeMap,
1139        sync::{Mutex, OnceLock},
1140        time::Instant,
1141    };
1142
1143    static SUPPRESSED: OnceLock<bool> = OnceLock::new();
1144    if *SUPPRESSED.get_or_init(|| {
1145        let v = std::env::var("AZ_SUPPRESS")
1146            .or_else(|_| std::env::var("AZ_SUPRESS"))
1147            .unwrap_or_default();
1148        v.split(',')
1149            .any(|t| t.trim().eq_ignore_ascii_case(IMAGE_CHURN_SUPPRESS_TAG))
1150    }) {
1151        return;
1152    }
1153
1154    // Per node: how many times it re-initialised, when that window started, and
1155    // whether we have already said so. "The time it was last updated and how
1156    // much" is the whole state — no history, no allocation per event.
1157    let churn = image_churn_state();
1158    let Ok(mut map) = churn.lock() else {
1159        return; // a poisoned lint counter must never take the app down
1160    };
1161
1162    let now = Instant::now();
1163    let entry = map.entry(node_index).or_insert((0, now, false));
1164    if now.duration_since(entry.1).as_secs_f32() >= 1.0 {
1165        *entry = (1, now, entry.2);
1166        return;
1167    }
1168    entry.0 += 1;
1169
1170    // Warn once per node. This runs on every rebuild of a resizing window; a
1171    // warning per frame would bury the message it is trying to deliver.
1172    if entry.0 < IMAGE_CHURN_PER_SEC || entry.2 {
1173        return;
1174    }
1175    entry.2 = true;
1176    let rate = entry.0;
1177
1178    if carried {
1179        crate::diagnostics::emit(format!(
1180            "[azul][image-churn] node {node_index} rebuilt its image as a \
1181             PLACEHOLDER {rate}x in one second. The previous frame was carried \
1182             forward each time, so nothing flickers — but a live image node is \
1183             being reconstructed every frame. If this is not a capture widget, \
1184             build the node once and update it through the image cache. \
1185             (suppress with AZ_SUPPRESS={IMAGE_CHURN_SUPPRESS_TAG})"
1186        ));
1187    } else {
1188        crate::diagnostics::emit(format!(
1189            "[azul][image-churn] node {node_index} rebuilt its image as a \
1190             PLACEHOLDER {rate}x in one second and the previous frame could NOT \
1191             be carried forward: this node has NO DATASET + merge callback, so \
1192             the reconciler cannot tell the rebuilt node is the same widget. The \
1193             live image is discarded every frame and the node falls back to its \
1194             placeholder until the next one arrives — a continuous flicker. If \
1195             this is a video or camera node, it is almost certainly missing its \
1196             dataset: attach one with a DatasetMergeCallback (see MapWidget / \
1197             ScreenCaptureWidget). \
1198             (suppress with AZ_SUPPRESS={IMAGE_CHURN_SUPPRESS_TAG})"
1199        ));
1200    }
1201}
1202
1203#[cfg(not(feature = "std"))]
1204fn note_image_reinitialised(_node_index: usize, _carried: bool) {}
1205
1206/// Executes state migration between the old DOM and the new DOM based on diff results.
1207///
1208/// This iterates through matched nodes. If a match has BOTH a merge callback AND a dataset,
1209/// it executes the callback to transfer state from the old node to the new node.
1210///
1211/// This must be called **before** the old DOM is dropped, because we need to access its data.
1212///
1213/// # Arguments
1214/// * `old_node_data` - Mutable reference to the old DOM's node data (source of heavy state)
1215/// * `new_node_data` - Mutable reference to the new DOM's node data (target for heavy state)
1216/// * `node_moves` - The matched nodes from the reconciliation diff
1217///
1218/// # Example
1219/// ```rust,ignore
1220/// let diff_result = reconcile_dom(&old_data, &new_data, ...);
1221///
1222/// // Execute state migration BEFORE old_dom is dropped
1223/// transfer_states(&mut old_data, &mut new_data, &diff_result.node_moves);
1224///
1225/// // Now safe to drop old_dom - heavy resources have been transferred
1226/// drop(old_dom);
1227/// ```
1228pub fn transfer_states(
1229    old_node_data: &mut [NodeData],
1230    new_node_data: &mut [NodeData],
1231    node_moves: &[NodeMove],
1232) {
1233    use crate::refany::OptionRefAny;
1234
1235    for movement in node_moves {
1236        let old_idx = movement.old_node_id.index();
1237        let new_idx = movement.new_node_id.index();
1238
1239        // Bounds check
1240        if old_idx >= old_node_data.len() || new_idx >= new_node_data.len() {
1241            continue;
1242        }
1243
1244        // 1. Check if the NEW node has requested a merge callback
1245        let Some(merge_callback) = new_node_data[new_idx].get_merge_callback() else {
1246            // No merge callback — nothing can be carried forward. If this node
1247            // is an image that just reverted to a placeholder while the old
1248            // build held a real frame, that live frame is being DISCARDED, and
1249            // at frame rate it is a visible flicker. This is the "forgot the
1250            // dataset on a video node" case, and the framework can see it
1251            // without anyone asking.
1252            if new_node_data[new_idx].image_is_placeholder()
1253                && !old_node_data[old_idx].image_is_placeholder()
1254            {
1255                note_image_reinitialised(new_idx, false);
1256            }
1257            continue; // No merge callback, skip
1258        };
1259
1260        // 2. Check if BOTH nodes have datasets
1261        // We need to temporarily take the datasets to satisfy borrow checker
1262        let old_dataset = old_node_data[old_idx].take_dataset();
1263        let new_dataset = new_node_data[new_idx].take_dataset();
1264
1265        match (new_dataset, old_dataset) {
1266            (Some(new_data), Some(old_data)) => {
1267                // The fresh DOM's dataset allocation. A widget builds its dataset,
1268                // its VirtualView content `refany`, AND its event-callback
1269                // `refany`s from clones of ONE `RefAny` — so every one shares THIS
1270                // allocation (`RefAny::clone` shares `sharing_info`; only the
1271                // per-clone `instance_id` differs). The merge below keeps the
1272                // PERSISTENT (old) allocation (e.g. MapWidget shares its tile cache
1273                // so background fetch threads keep writing into it), so every clone
1274                // of the fresh one is now orphaned and must be re-pointed — or the
1275                // widget fragments across two caches: the VirtualView rendered an
1276                // empty clone (blank/grey tiles) while the live data sat in the
1277                // dataset, and pan/zoom mutated yet a third copy. Identity = the
1278                // shared `RefCountInner` pointer (`sharing_info.ptr`).
1279                let orphan_alloc = new_data.sharing_info.ptr as usize;
1280
1281                // 3. EXECUTE THE MERGE CALLBACK
1282                // The callback receives both datasets and returns the merged result
1283                let merged = (merge_callback.cb)(new_data, old_data);
1284
1285                // 3b. CARRY THE LIVE IMAGE FORWARD.
1286                //
1287                // A merge callback ran, so this is the SAME logical widget as
1288                // before — the reconciler matched them and the widget asked for
1289                // its state to persist. A capture widget rebuilds its node with
1290                // a PLACEHOLDER every time (`Dom::create_image(null_image)`),
1291                // because the fresh widget struct has no frame yet; the live
1292                // frame arrives later by writeback. So on every DOM rebuild the
1293                // node reverted to the placeholder and stayed there until the
1294                // next frame landed ~16-33ms later.
1295                //
1296                // That is the flash reported when resizing a window while
1297                // screensharing: "the screen flickers, like it is
1298                // re-initializing". Nothing was re-initialising — the last frame
1299                // was simply thrown away and re-awaited.
1300                //
1301                // NARROW ON PURPOSE: only when the NEW image is a null/
1302                // placeholder image and the OLD one is not. An app that
1303                // deliberately swaps in a real image still wins, and one that
1304                // deliberately clears to a placeholder is the only case this
1305                // changes — which is indistinguishable from "has not produced a
1306                // frame yet" and is what the widget itself does every rebuild.
1307                if new_node_data[new_idx].image_is_placeholder()
1308                    && !old_node_data[old_idx].image_is_placeholder()
1309                {
1310                    if let Some(prev) = old_node_data[old_idx].get_image_ref_cloned() {
1311                        new_node_data[new_idx].set_image_ref(prev);
1312                    }
1313                    // Handled — but still worth saying if it happens every
1314                    // frame, because rebuilding a live image node at 60 Hz is
1315                    // work nobody asked for.
1316                    note_image_reinitialised(new_idx, true);
1317                }
1318
1319                // 4. Store the merged result back in the new node
1320                new_node_data[new_idx].set_dataset(OptionRefAny::Some(merged.clone()));
1321
1322                // 5. UNIFY: re-point every refany across the NEW DOM that was a
1323                // clone of the now-discarded fresh dataset onto the merged result,
1324                // so the whole widget reads ONE cache. Covers VirtualView content
1325                // refanys + event-callback refanys + any node's dataset cloned
1326                // from the same source. (Generalises the old special-case that
1327                // only re-pointed a VirtualView ON the merge node itself — the
1328                // MapWidget puts its VirtualView in a CHILD and its pan/zoom
1329                // callbacks on the parent, which that case missed.)
1330                repoint_orphaned_refanys(new_node_data, orphan_alloc, &merged);
1331            }
1332            (new_ds, old_ds) => {
1333                // One or both datasets missing - restore what we had
1334                if let Some(ds) = new_ds {
1335                    new_node_data[new_idx].set_dataset(OptionRefAny::Some(ds));
1336                }
1337                if let Some(ds) = old_ds {
1338                    old_node_data[old_idx].set_dataset(OptionRefAny::Some(ds));
1339                }
1340            }
1341        }
1342    }
1343}
1344
1345/// Re-point every `RefAny` across `node_data` that is a clone of the
1346/// allocation `orphan_alloc` (a dataset the merge discarded) at `merged`.
1347///
1348/// The whole widget then reads ONE state: `VirtualView` content refanys,
1349/// event callback refanys and datasets cloned from the same source. The
1350/// `MapWidget` puts its `VirtualView` in a CHILD and its pan/zoom callbacks
1351/// on the parent, which is why this scans the whole arena and not just the
1352/// merge node.
1353fn repoint_orphaned_refanys(node_data: &mut [NodeData], orphan_alloc: usize, merged: &RefAny) {
1354    use crate::refany::OptionRefAny;
1355    if merged.sharing_info.ptr as usize == orphan_alloc {
1356        return; // the merge kept the fresh allocation: nothing is orphaned
1357    }
1358    for nd in node_data.iter_mut() {
1359        if let Some(vv) = nd.get_virtual_view_node() {
1360            if vv.refany.sharing_info.ptr as usize == orphan_alloc {
1361                vv.refany = merged.clone();
1362            }
1363        }
1364        for cb in nd.callbacks.as_mut().iter_mut() {
1365            if cb.refany.sharing_info.ptr as usize == orphan_alloc {
1366                cb.refany = merged.clone();
1367            }
1368        }
1369        let ds_is_orphan = nd
1370            .get_dataset()
1371            .is_some_and(|ds| ds.sharing_info.ptr as usize == orphan_alloc);
1372        if ds_is_orphan {
1373            nd.set_dataset(OptionRefAny::Some(merged.clone()));
1374        }
1375    }
1376}
1377
1378/// The pre-cascade fast path's half of [`transfer_states`].
1379///
1380/// When the fresh build's fingerprints equal the retained DOM's, the cascade
1381/// is skipped and the retained `StyledDom` is kept; the fresh build's event
1382/// callbacks are installed on it (they may reference new app state). That
1383/// left the DATASETS behind: the fresh callbacks' `RefAny`s were clones of
1384/// the fresh build's dataset, the retained node kept last frame's, and no
1385/// merge callback ever ran — so a `RefreshDom` that rebuilt an identical DOM
1386/// reset every stateful widget's callback state (a slider's drag died on its
1387/// second move) and split the widget across two allocations, the exact
1388/// fragmentation [`repoint_orphaned_refanys`] exists to prevent.
1389///
1390/// Same rules as `transfer_states`, with the retained node as "old" and the
1391/// fresh dataset as "new": merge through the node's merge callback when it
1392/// has one, otherwise the fresh dataset wins; then re-point everything on the
1393/// retained DOM that was a clone of the fresh dataset at the result. Call it
1394/// AFTER the fresh callbacks have been installed on `node_data`, once per
1395/// fresh dataset, with `idx` the node's flattened index.
1396pub fn merge_fresh_dataset(node_data: &mut [NodeData], idx: usize, fresh: RefAny) {
1397    use crate::refany::OptionRefAny;
1398    let Some(nd) = node_data.get_mut(idx) else {
1399        return;
1400    };
1401    let orphan_alloc = fresh.sharing_info.ptr as usize;
1402    let merge_callback = nd.get_merge_callback();
1403    let retained = nd.take_dataset();
1404    let result = match (merge_callback, retained) {
1405        (Some(cb), Some(old)) => (cb.cb)(fresh, old),
1406        _ => fresh,
1407    };
1408    nd.set_dataset(OptionRefAny::Some(result.clone()));
1409    repoint_orphaned_refanys(node_data, orphan_alloc, &result);
1410}
1411
1412/// Calculate a stable key for a contenteditable node using the hierarchy:
1413///
1414/// 1. **Explicit Key** - If `.with_key()` was called, use that
1415/// 2. **CSS ID** - If the node has a CSS ID (e.g., `#my-editor`), hash that
1416/// 3. **Structural Key** - Hash of `(nth-of-type, parent_key)` recursively
1417///
1418/// The structural key prevents shifting when elements are inserted before siblings.
1419/// For example, in `<div><p>A</p><p contenteditable>B</p></div>`, if we insert
1420/// a new `<p>` at the start, the contenteditable `<p>` becomes nth-child(3) but
1421/// its nth-of-type stays stable (it's still the 2nd `<p>`).
1422///
1423/// # Arguments
1424/// * `node_data` - All nodes in the DOM
1425/// * `hierarchy` - Parent-child relationships
1426/// * `node_id` - The node to calculate the key for
1427///
1428/// # Returns
1429/// A stable u64 key for the node
1430#[must_use]
1431pub fn calculate_contenteditable_key(
1432    node_data: &[NodeData],
1433    hierarchy: &[NodeHierarchyItem],
1434    node_id: NodeId,
1435) -> u64 {
1436    use core::hash::Hasher;
1437
1438    let n = node_data.len();
1439
1440    // Terminal (parent-independent) key: Priority 1 explicit key, else
1441    // Priority 2 CSS ID, else `None` (structural — needs the parent chain).
1442    let terminal_key = |nid: NodeId| -> Option<u64> {
1443        let node = &node_data[nid.index()];
1444        // Priority 1: Explicit key (from .with_key())
1445        if let Some(explicit_key) = node.get_key() {
1446            return Some(explicit_key);
1447        }
1448        // Priority 2: CSS ID
1449        for attr in node.attributes().as_ref() {
1450            if let Some(id) = attr.as_id() {
1451                let mut hasher = crate::hash::DefaultHasher::new(); // Different seed for ID keys
1452                hasher.write(id.as_bytes());
1453                return Some(hasher.finish());
1454            }
1455        }
1456        None
1457    };
1458
1459    // Fast path: the node itself has an explicit key or CSS ID.
1460    if let Some(key) = terminal_key(node_id) {
1461        return key;
1462    }
1463
1464    // Priority 3: structural key, computed ITERATIVELY up the parent chain.
1465    //
1466    // AUDIT: replaces unbounded parent-chain recursion (stack overflow on deep
1467    // DOMs, infinite recursion on a cyclic hierarchy). Same fold as the old
1468    // recursion, unrolled bottom-up and bounded by the node count.
1469    let mut chain: Vec<NodeId> = Vec::new();
1470    let mut seed_parent_key: Option<u64> = None;
1471    let mut cur = node_id;
1472    for _ in 0..n {
1473        if cur.index() >= n {
1474            break;
1475        }
1476        chain.push(cur);
1477        match hierarchy
1478            .get(cur.index())
1479            .and_then(NodeHierarchyItem::parent_id)
1480        {
1481            None => break,
1482            Some(parent) => {
1483                if let Some(k) = terminal_key(parent) {
1484                    seed_parent_key = Some(k);
1485                    break;
1486                }
1487                cur = parent;
1488            }
1489        }
1490    }
1491
1492    // Fold from the topmost ancestor down to `node_id`. Unlike the
1493    // reconciliation key, the contenteditable structural key ALWAYS writes a
1494    // `parent_key` (0 at the root) and an `nth_of_type` (0 at the root), so the
1495    // per-level hashing is unconditional — preserve that exactly.
1496    let mut parent_key: u64 = seed_parent_key.unwrap_or(0);
1497    for &nid in chain.iter().rev() {
1498        let node = &node_data[nid.index()];
1499        let mut hasher = crate::hash::DefaultHasher::new(); // Different seed for structural keys
1500
1501        let node_parent = hierarchy
1502            .get(nid.index())
1503            .and_then(NodeHierarchyItem::parent_id);
1504
1505        // parent_key: 0 at the root, else the accumulated key of the level above.
1506        let level_parent_key = if node_parent.is_some() { parent_key } else { 0 };
1507        hasher.write(&level_parent_key.to_le_bytes());
1508
1509        // nth-of-type: count same-discriminant siblings before `nid`.
1510        let node_discriminant = core::mem::discriminant(node.get_node_type());
1511        let nth_of_type = node_parent.map_or(0u32, |parent_id| {
1512            let mut count = 0u32;
1513            let mut sibling_id = hierarchy
1514                .get(parent_id.index())
1515                .and_then(|h| h.first_child_id(parent_id));
1516            while let Some(sib_id) = sibling_id {
1517                if sib_id == nid {
1518                    break;
1519                }
1520                let sibling_discriminant =
1521                    core::mem::discriminant(node_data[sib_id.index()].get_node_type());
1522                if sibling_discriminant == node_discriminant {
1523                    count += 1;
1524                }
1525                sibling_id = hierarchy
1526                    .get(sib_id.index())
1527                    .and_then(NodeHierarchyItem::next_sibling_id);
1528            }
1529            count
1530        });
1531        hasher.write(&nth_of_type.to_le_bytes());
1532
1533        // Hash the node type discriminant (Discriminant<T> implements Hash)
1534        node_discriminant.hash(&mut hasher);
1535
1536        // Also hash the classes for additional stability
1537        for attr in node.attributes().as_ref() {
1538            if let Some(class) = attr.as_class() {
1539                hasher.write(class.as_bytes());
1540            }
1541        }
1542
1543        parent_key = hasher.finish();
1544    }
1545
1546    parent_key
1547}
1548
1549/// Reconcile cursor byte position when text content changes.
1550///
1551/// This function maps a cursor position from old text to new text, preserving
1552/// the cursor's logical position as much as possible:
1553///
1554/// 1. If cursor is in unchanged prefix → stays at same byte offset
1555/// 2. If cursor is in unchanged suffix → adjusts by length difference
1556/// 3. If cursor is in changed region → places at end of new content
1557///
1558/// # Arguments
1559/// * `old_text` - The previous text content
1560/// * `new_text` - The new text content
1561/// * `old_cursor_byte` - Cursor byte offset in old text
1562///
1563/// # Returns
1564/// The reconciled cursor byte offset in new text
1565///
1566/// # Example
1567/// ```rust,ignore
1568/// let old_text = "Hello";
1569/// let new_text = "Hello World";
1570/// let old_cursor = 5; // cursor at end of "Hello"
1571/// let new_cursor = reconcile_cursor_position(old_text, new_text, old_cursor);
1572/// assert_eq!(new_cursor, 5); // cursor stays at same position (prefix unchanged)
1573/// ```
1574#[must_use]
1575pub fn reconcile_cursor_position(old_text: &str, new_text: &str, old_cursor_byte: usize) -> usize {
1576    // AUDIT: every returned offset is snapped DOWN to the nearest UTF-8 char
1577    // boundary in `new_text` (and clamped to its length). The prefix/suffix
1578    // scans below compare byte-by-byte and can land mid-codepoint, so a raw
1579    // return value could later panic when used to slice `new_text` as a `str`.
1580    let snap = |offset: usize| -> usize {
1581        let mut o = offset.min(new_text.len());
1582        while o > 0 && !new_text.is_char_boundary(o) {
1583            o -= 1;
1584        }
1585        o
1586    };
1587
1588    // If texts are equal, cursor is unchanged
1589    if old_text == new_text {
1590        return snap(old_cursor_byte);
1591    }
1592
1593    // Empty old text - place cursor at end of new text
1594    if old_text.is_empty() {
1595        return new_text.len();
1596    }
1597
1598    // Empty new text - place cursor at 0
1599    if new_text.is_empty() {
1600        return 0;
1601    }
1602
1603    // Find common prefix (how many bytes from the start are identical)
1604    let common_prefix_bytes = old_text
1605        .bytes()
1606        .zip(new_text.bytes())
1607        .take_while(|(a, b)| a == b)
1608        .count();
1609
1610    // If cursor was in the unchanged prefix, it stays at the same byte offset
1611    if old_cursor_byte <= common_prefix_bytes {
1612        return snap(old_cursor_byte);
1613    }
1614
1615    // Find common suffix (how many bytes from the end are identical)
1616    let common_suffix_bytes = old_text
1617        .bytes()
1618        .rev()
1619        .zip(new_text.bytes().rev())
1620        .take_while(|(a, b)| a == b)
1621        .count();
1622
1623    // Calculate where the suffix starts in old and new text
1624    let old_suffix_start = old_text.len().saturating_sub(common_suffix_bytes);
1625    let new_suffix_start = new_text.len().saturating_sub(common_suffix_bytes);
1626
1627    // If cursor was in the unchanged suffix, adjust by length difference
1628    if old_cursor_byte >= old_suffix_start {
1629        // saturating: an out-of-range cursor (> old_text.len()) must clamp to the
1630        // end of the new text like every other path here, not underflow-panic.
1631        let offset_from_end = old_text.len().saturating_sub(old_cursor_byte);
1632        return snap(new_text.len().saturating_sub(offset_from_end));
1633    }
1634
1635    // Cursor was in the changed region - place at end of inserted content
1636    // This handles insertions (cursor moves with new text) and deletions (cursor at edit point)
1637    snap(new_suffix_start)
1638}
1639
1640/// Get the text content from a `NodeData` if it's a Text node.
1641///
1642/// Returns the text string if the node is `NodeType::Text`, otherwise `None`.
1643#[must_use]
1644pub fn get_node_text_content(node: &NodeData) -> Option<&str> {
1645    if let NodeType::Text(ref text) = node.get_node_type() {
1646        Some(text.as_str())
1647    } else {
1648        None
1649    }
1650}
1651
1652// ============================================================================
1653// ChangeAccumulator — unifies all change input paths
1654// ============================================================================
1655
1656/// Text change info for cursor/selection reconciliation.
1657#[derive(Debug, Clone, PartialEq, Eq)]
1658pub struct TextChange {
1659    /// The text content before the change.
1660    pub old_text: String,
1661    /// The text content after the change.
1662    pub new_text: String,
1663}
1664
1665/// Per-node change report combining multiple information sources.
1666#[derive(Debug, Clone, Default)]
1667pub struct NodeChangeReport {
1668    /// Bitflags from DOM-level field comparison.
1669    pub change_set: NodeChangeSet,
1670
1671    /// Highest `RelayoutScope` from any CSS property that changed on this node.
1672    /// This is more granular than `NodeChangeSet`'s binary LAYOUT/PAINT split.
1673    ///
1674    /// - `None` → repaint only (color, opacity, transform)
1675    /// - `IfcOnly` → reshape text in the containing IFC
1676    /// - `SizingOnly` → recompute this node's intrinsic size
1677    /// - `Full` → full subtree relayout (display, position, float, etc.)
1678    pub relayout_scope: RelayoutScope,
1679
1680    /// Individual CSS properties that changed (for fine-grained cache invalidation).
1681    /// Empty if the change was structural (text content, node type, etc.)
1682    pub changed_css_properties: Vec<CssPropertyType>,
1683
1684    /// If text content changed, the old and new text for cursor reconciliation.
1685    pub text_change: Option<TextChange>,
1686}
1687
1688impl NodeChangeReport {
1689    /// Returns the `DirtyFlag` level needed for this change report.
1690    /// Maps `RelayoutScope` + `NodeChangeSet` → a simple tri-state.
1691    #[must_use]
1692    pub fn needs_layout(&self) -> bool {
1693        self.change_set.needs_layout() || self.relayout_scope > RelayoutScope::None
1694    }
1695
1696    #[must_use]
1697    pub const fn needs_paint(&self) -> bool {
1698        self.change_set.needs_paint()
1699    }
1700
1701    #[must_use]
1702    pub fn is_visually_unchanged(&self) -> bool {
1703        self.change_set.is_visually_unchanged() && self.relayout_scope == RelayoutScope::None
1704    }
1705}
1706
1707/// Unified change report that merges information from all three change paths:
1708///
1709/// 1. **DOM reconciliation** (`compute_node_changes` after `reconcile_dom`)
1710/// 2. **CSS restyle** (`restyle_on_state_change` for hover/focus/active)
1711/// 3. **Runtime edits** (`words_changed`, `css_properties_changed`, `images_changed`)
1712///
1713/// This is the single source of truth for "what work needs to happen this frame".
1714#[derive(Debug, Clone, Default)]
1715pub struct ChangeAccumulator {
1716    /// Per-node change info. Key is the new-DOM `NodeId`.
1717    pub per_node: BTreeMap<NodeId, NodeChangeReport>,
1718
1719    /// Maximum `RelayoutScope` across all changed nodes.
1720    /// Quick check: if this is `None`, we can skip layout entirely.
1721    pub max_scope: RelayoutScope,
1722
1723    /// Nodes that are newly mounted (no old counterpart).
1724    /// These always need full layout.
1725    pub mounted_nodes: Vec<NodeId>,
1726
1727    /// Nodes that were unmounted (no new counterpart).
1728    /// Used for cleanup (remove from scroll/focus/cursor managers).
1729    pub unmounted_nodes: Vec<NodeId>,
1730}
1731
1732impl ChangeAccumulator {
1733    #[must_use]
1734    pub fn new() -> Self {
1735        Self::default()
1736    }
1737
1738    /// Returns true if no changes were detected at all.
1739    #[must_use]
1740    pub fn is_empty(&self) -> bool {
1741        self.per_node.is_empty() && self.mounted_nodes.is_empty() && self.unmounted_nodes.is_empty()
1742    }
1743
1744    /// Returns true if layout work is needed (any node has scope > None).
1745    #[must_use]
1746    pub fn needs_layout(&self) -> bool {
1747        self.max_scope > RelayoutScope::None
1748            || !self.mounted_nodes.is_empty()
1749            || self.per_node.values().any(NodeChangeReport::needs_layout)
1750    }
1751
1752    /// Returns true if only paint work is needed (no layout).
1753    #[must_use]
1754    pub fn needs_paint_only(&self) -> bool {
1755        !self.needs_layout() && self.per_node.values().any(NodeChangeReport::needs_paint)
1756    }
1757
1758    /// Returns true if only non-visual changes occurred (callbacks, dataset, a11y).
1759    #[must_use]
1760    pub fn is_visually_unchanged(&self) -> bool {
1761        self.mounted_nodes.is_empty()
1762            && self.unmounted_nodes.is_empty()
1763            && self.max_scope == RelayoutScope::None
1764            && self
1765                .per_node
1766                .values()
1767                .all(NodeChangeReport::is_visually_unchanged)
1768    }
1769
1770    /// Add a node change from DOM reconciliation (Path A).
1771    pub fn add_dom_change(
1772        &mut self,
1773        new_node_id: NodeId,
1774        change_set: NodeChangeSet,
1775        relayout_scope: RelayoutScope,
1776        text_change: Option<TextChange>,
1777        changed_css_properties: Vec<CssPropertyType>,
1778    ) {
1779        if relayout_scope > self.max_scope {
1780            self.max_scope = relayout_scope;
1781        }
1782
1783        let report = self.per_node.entry(new_node_id).or_default();
1784        report.change_set |= change_set;
1785        if relayout_scope > report.relayout_scope {
1786            report.relayout_scope = relayout_scope;
1787        }
1788        if text_change.is_some() {
1789            report.text_change = text_change;
1790        }
1791        report.changed_css_properties.extend(changed_css_properties);
1792    }
1793
1794    /// Add a text change (from runtime edit or DOM reconciliation).
1795    pub fn add_text_change(&mut self, node_id: NodeId, old_text: String, new_text: String) {
1796        let scope = RelayoutScope::IfcOnly;
1797        if scope > self.max_scope {
1798            self.max_scope = scope;
1799        }
1800
1801        let report = self.per_node.entry(node_id).or_default();
1802        report.change_set.insert(NodeChangeSet::TEXT_CONTENT);
1803        if scope > report.relayout_scope {
1804            report.relayout_scope = scope;
1805        }
1806        report.text_change = Some(TextChange { old_text, new_text });
1807    }
1808
1809    /// Add a CSS property change (from runtime edit or restyle).
1810    pub fn add_css_change(
1811        &mut self,
1812        node_id: NodeId,
1813        prop_type: CssPropertyType,
1814        scope: RelayoutScope,
1815    ) {
1816        if scope > self.max_scope {
1817            self.max_scope = scope;
1818        }
1819
1820        let report = self.per_node.entry(node_id).or_default();
1821        if scope > RelayoutScope::None {
1822            report.change_set.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
1823        } else {
1824            report.change_set.insert(NodeChangeSet::INLINE_STYLE_PAINT);
1825        }
1826        if scope > report.relayout_scope {
1827            report.relayout_scope = scope;
1828        }
1829        report.changed_css_properties.push(prop_type);
1830    }
1831
1832    /// Add an image change (from runtime edit or DOM reconciliation).
1833    pub fn add_image_change(&mut self, node_id: NodeId, scope: RelayoutScope) {
1834        if scope > self.max_scope {
1835            self.max_scope = scope;
1836        }
1837
1838        let report = self.per_node.entry(node_id).or_default();
1839        report.change_set.insert(NodeChangeSet::IMAGE_CHANGED);
1840        if scope > report.relayout_scope {
1841            report.relayout_scope = scope;
1842        }
1843    }
1844
1845    /// Add a mounted (new) node.
1846    pub fn add_mount(&mut self, node_id: NodeId) {
1847        self.mounted_nodes.push(node_id);
1848    }
1849
1850    /// Add an unmounted (removed) node.
1851    pub fn add_unmount(&mut self, node_id: NodeId) {
1852        self.unmounted_nodes.push(node_id);
1853    }
1854
1855    /// Merge a `RestyleResult` (from `restyle_on_state_change()`) into this accumulator.
1856    ///
1857    /// This is the bridge between Path B (restyle) and the unified change pipeline.
1858    /// Each `ChangedCssProperty` is classified via `relayout_scope()` to determine
1859    /// whether it affects layout or only paint.
1860    pub fn merge_restyle_result(&mut self, restyle: &crate::styled_dom::RestyleResult) {
1861        for (node_id, changed_props) in &restyle.changed_nodes {
1862            for changed in changed_props {
1863                let prop_type = changed.current_prop.get_type();
1864                let scope = prop_type.relayout_scope(true); // conservative
1865                self.add_css_change(*node_id, prop_type, scope);
1866            }
1867        }
1868    }
1869
1870    /// Populate this accumulator from an `ExtendedDiffResult` + the old/new DOM data.
1871    ///
1872    /// This converts per-node `NodeChangeSet` flags into full `NodeChangeReport`s
1873    /// with `RelayoutScope` classification.
1874    pub fn merge_extended_diff(
1875        &mut self,
1876        extended: &ExtendedDiffResult,
1877        old_node_data: &[NodeData],
1878        new_node_data: &[NodeData],
1879    ) {
1880        for &(old_id, new_id, ref change_set) in &extended.node_changes {
1881            if change_set.is_empty() {
1882                continue;
1883            }
1884
1885            // Determine RelayoutScope from the change flags
1886            let scope = Self::classify_change_scope(*change_set, new_node_data, new_id);
1887
1888            // Extract text change info if TEXT_CONTENT flag is set
1889            let text_change = if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
1890                let old_text = get_node_text_content(&old_node_data[old_id.index()])
1891                    .unwrap_or("")
1892                    .to_string();
1893                let new_text = get_node_text_content(&new_node_data[new_id.index()])
1894                    .unwrap_or("")
1895                    .to_string();
1896                Some(TextChange { old_text, new_text })
1897            } else {
1898                None
1899            };
1900
1901            self.add_dom_change(new_id, *change_set, scope, text_change, Vec::new());
1902        }
1903
1904        // Track mounts: new nodes that didn't match anything in old
1905        let matched_new: alloc::collections::BTreeSet<usize> = extended
1906            .diff
1907            .node_moves
1908            .iter()
1909            .map(|m| m.new_node_id.index())
1910            .collect();
1911
1912        for idx in 0..new_node_data.len() {
1913            if !matched_new.contains(&idx) {
1914                self.add_mount(NodeId::new(idx));
1915            }
1916        }
1917
1918        // Track unmounts: old nodes that didn't match anything in new
1919        let matched_old: alloc::collections::BTreeSet<usize> = extended
1920            .diff
1921            .node_moves
1922            .iter()
1923            .map(|m| m.old_node_id.index())
1924            .collect();
1925
1926        for idx in 0..old_node_data.len() {
1927            if !matched_old.contains(&idx) {
1928                self.add_unmount(NodeId::new(idx));
1929            }
1930        }
1931    }
1932
1933    /// Classify a `NodeChangeSet` into the appropriate `RelayoutScope`.
1934    fn classify_change_scope(
1935        change_set: NodeChangeSet,
1936        new_node_data: &[NodeData],
1937        new_node_id: NodeId,
1938    ) -> RelayoutScope {
1939        // NODE_TYPE_CHANGED or CHILDREN_CHANGED → Full
1940        if change_set.contains(NodeChangeSet::NODE_TYPE_CHANGED)
1941            || change_set.contains(NodeChangeSet::CHILDREN_CHANGED)
1942        {
1943            return RelayoutScope::Full;
1944        }
1945
1946        // IDS_AND_CLASSES → Full (conservative: class change may add layout-affecting CSS)
1947        if change_set.contains(NodeChangeSet::IDS_AND_CLASSES) {
1948            return RelayoutScope::Full;
1949        }
1950
1951        // INLINE_STYLE_LAYOUT → could be IfcOnly, SizingOnly, or Full
1952        // We need to check individual properties for the exact scope.
1953        // For now, we use SizingOnly as a conservative default since
1954        // the individual property scopes were already checked in compute_node_changes.
1955        if change_set.contains(NodeChangeSet::INLINE_STYLE_LAYOUT) {
1956            // Walk the inline CSS properties to find the max scope
1957            let new_node = &new_node_data[new_node_id.index()];
1958            let mut max_scope = RelayoutScope::None;
1959            for (prop, _conds) in new_node.style.iter_inline_properties() {
1960                let scope = prop.get_type().relayout_scope(true);
1961                if scope > max_scope {
1962                    max_scope = scope;
1963                }
1964            }
1965            return if max_scope == RelayoutScope::None {
1966                RelayoutScope::SizingOnly // conservative fallback
1967            } else {
1968                max_scope
1969            };
1970        }
1971
1972        // TEXT_CONTENT → IfcOnly (reshape text, may cascade)
1973        if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
1974            return RelayoutScope::IfcOnly;
1975        }
1976
1977        // IMAGE_CHANGED → SizingOnly (intrinsic size may change)
1978        if change_set.contains(NodeChangeSet::IMAGE_CHANGED) {
1979            return RelayoutScope::SizingOnly;
1980        }
1981
1982        // CONTENTEDITABLE → SizingOnly
1983        if change_set.contains(NodeChangeSet::CONTENTEDITABLE) {
1984            return RelayoutScope::SizingOnly;
1985        }
1986
1987        // Paint-only or no-visual changes
1988        if change_set.intersects(NodeChangeSet::AFFECTS_PAINT) {
1989            return RelayoutScope::None;
1990        }
1991
1992        RelayoutScope::None
1993    }
1994}
1995
1996/// Perform a full reconciliation with change detection.
1997///
1998/// This combines `reconcile_dom()` + `compute_node_changes()` into a single
1999/// pass that produces an `ExtendedDiffResult` with per-node change flags.
2000///
2001/// The `ChangeAccumulator` can then be populated from the result via
2002/// `accumulator.merge_extended_diff()`.
2003#[must_use]
2004pub fn reconcile_dom_with_changes(
2005    old_node_data: &[NodeData],
2006    new_node_data: &[NodeData],
2007    old_hierarchy: &[NodeHierarchyItem],
2008    new_hierarchy: &[NodeHierarchyItem],
2009    old_styled_nodes: Option<&[StyledNodeState]>,
2010    new_styled_nodes: Option<&[StyledNodeState]>,
2011    old_layout: &OrderedMap<NodeId, LogicalRect>,
2012    new_layout: &OrderedMap<NodeId, LogicalRect>,
2013    dom_id: DomId,
2014    timestamp: Instant,
2015) -> ExtendedDiffResult {
2016    // Step 1: Run standard reconciliation
2017    let diff = reconcile_dom(
2018        old_node_data,
2019        new_node_data,
2020        old_hierarchy,
2021        new_hierarchy,
2022        old_layout,
2023        new_layout,
2024        dom_id,
2025        timestamp,
2026    );
2027
2028    // Step 2: For each matched pair, compute what changed
2029    let mut node_changes = Vec::new();
2030    for node_move in &diff.node_moves {
2031        let old_nd = &old_node_data[node_move.old_node_id.index()];
2032        let new_nd = &new_node_data[node_move.new_node_id.index()];
2033
2034        let old_state = old_styled_nodes.and_then(|s| s.get(node_move.old_node_id.index()));
2035        let new_state = new_styled_nodes.and_then(|s| s.get(node_move.new_node_id.index()));
2036
2037        let changes = compute_node_changes(old_nd, new_nd, old_state, new_state);
2038        node_changes.push((node_move.old_node_id, node_move.new_node_id, changes));
2039    }
2040
2041    ExtendedDiffResult { diff, node_changes }
2042}
2043
2044// ============================================================================
2045// NodeDataFingerprint — multi-field hash for fast change detection
2046// ============================================================================
2047
2048/// Per-node hash broken into independent fields for fast change detection.
2049///
2050/// Instead of a single u64 hash (which loses all granularity), this stores
2051/// separate hashes per field category. Comparing two fingerprints is O(1)
2052/// (6 integer comparisons) and immediately tells us WHICH category changed,
2053/// avoiding the more expensive `compute_node_changes()` for unchanged nodes.
2054///
2055/// Two-tier strategy:
2056/// - **Tier 1** (this struct): O(1) per node, identifies which categories changed.
2057/// - **Tier 2** (`compute_node_changes`): O(n) per changed field, does field-by-field
2058///   comparison only for nodes that Tier 1 identified as changed.
2059#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
2060pub struct NodeDataFingerprint {
2061    /// Hash of `node_type` (Text content, Image ref, Div, etc.)
2062    pub content_hash: u64,
2063    /// Hash of `styled_node_state` (hover, focus, active bits)
2064    pub state_hash: u64,
2065    /// Hash of inline CSS properties
2066    pub inline_css_hash: u64,
2067    /// Hash of `ids_and_classes`
2068    pub ids_classes_hash: u64,
2069    /// Hash of callbacks (event types + function pointers)
2070    pub callbacks_hash: u64,
2071    /// Hash of the layout-relevant attributes (contenteditable, flags)
2072    pub attrs_hash: u64,
2073    /// Hash of the dataset's PRESENCE and TYPE — never its allocation.
2074    ///
2075    /// A widget's dataset is state, not layout. Most widgets allocate a fresh
2076    /// `RefAny` on every build, and `RefAny` hashes by pointer, so hashing the
2077    /// dataset into `attrs_hash` (which maps to `CONTENTEDITABLE`, a layout
2078    /// change) made every `with_dataset` node LAYOUT-DIRTY on every
2079    /// `RefreshDom`: the `TextArea` became a standalone layout root on each
2080    /// callback, was re-laid-out with its `min-height` while its flex
2081    /// container kept the old slot, and painted 64 px into a 36 px slot — over
2082    /// the slider beneath it. A dataset change is [`NodeChangeSet::DATASET`],
2083    /// which affects neither layout nor paint.
2084    pub dataset_hash: u64,
2085}
2086
2087impl NodeDataFingerprint {
2088    /// Compute a fingerprint from a node's data and styled state.
2089    #[must_use]
2090    pub fn compute(node: &NodeData, styled_state: Option<&StyledNodeState>) -> Self {
2091        use core::hash::Hash;
2092        use core::hash::Hasher;
2093
2094        // Content hash
2095        let content_hash = {
2096            let mut h = crate::hash::DefaultHasher::new();
2097            node.get_node_type().hash(&mut h);
2098            h.finish()
2099        };
2100
2101        // State hash
2102        let state_hash = {
2103            let mut h = crate::hash::DefaultHasher::new();
2104            if let Some(state) = styled_state {
2105                state.hash(&mut h);
2106            }
2107            h.finish()
2108        };
2109
2110        // Inline CSS hash — full CssProperty value (matches the legacy
2111        // CssPropertyWithConditions::hash that hashed both property and the
2112        // condition vec length).
2113        let inline_css_hash = {
2114            let mut h = crate::hash::DefaultHasher::new();
2115            for (prop, conds) in node.style.iter_inline_properties() {
2116                prop.hash(&mut h);
2117                conds.as_slice().len().hash(&mut h);
2118            }
2119            h.finish()
2120        };
2121
2122        // IDs and classes hash (now stored in attributes)
2123        let ids_classes_hash = {
2124            let mut h = crate::hash::DefaultHasher::new();
2125            for attr in node.attributes().as_ref() {
2126                match attr {
2127                    crate::dom::AttributeType::Id(s) => {
2128                        crate::dom::IdOrClass::Id(s.clone()).hash(&mut h);
2129                    }
2130                    crate::dom::AttributeType::Class(s) => {
2131                        crate::dom::IdOrClass::Class(s.clone()).hash(&mut h);
2132                    }
2133                    _ => {}
2134                }
2135            }
2136            h.finish()
2137        };
2138
2139        // Callbacks hash
2140        let callbacks_hash = {
2141            let mut h = crate::hash::DefaultHasher::new();
2142            for cb in node.callbacks.as_ref() {
2143                cb.event.hash(&mut h);
2144                cb.callback.hash(&mut h);
2145            }
2146            h.finish()
2147        };
2148
2149        // Attributes hash — the layout-relevant ones only
2150        let attrs_hash = {
2151            let mut h = crate::hash::DefaultHasher::new();
2152            node.is_contenteditable().hash(&mut h);
2153            node.flags.hash(&mut h);
2154            h.finish()
2155        };
2156
2157        // Dataset hash: presence + type, NOT the allocation (see the field doc).
2158        let dataset_hash = {
2159            let mut h = crate::hash::DefaultHasher::new();
2160            match node.get_dataset() {
2161                Some(ds) => {
2162                    true.hash(&mut h);
2163                    ds.get_type_id().hash(&mut h);
2164                }
2165                None => false.hash(&mut h),
2166            }
2167            h.finish()
2168        };
2169
2170        Self {
2171            content_hash,
2172            state_hash,
2173            inline_css_hash,
2174            ids_classes_hash,
2175            callbacks_hash,
2176            attrs_hash,
2177            dataset_hash,
2178        }
2179    }
2180
2181    /// Returns a quick `NodeChangeSet` by comparing two fingerprints.
2182    /// This is O(1) — just comparing 7 u64s.
2183    ///
2184    /// The result is *conservative*: if a field hash differs, we set the
2185    /// broadest applicable flag. For precise classification (e.g., which
2186    /// CSS properties changed and their `relayout_scope()`), the caller
2187    /// should fall back to `compute_node_changes()` for changed nodes.
2188    #[must_use]
2189    pub const fn diff(&self, other: &Self) -> NodeChangeSet {
2190        let mut changes = NodeChangeSet::empty();
2191
2192        if self.content_hash != other.content_hash {
2193            // Could be TEXT_CONTENT, IMAGE_CHANGED, or NODE_TYPE_CHANGED
2194            // We set both TEXT_CONTENT and IMAGE_CHANGED conservatively;
2195            // compute_node_changes() will refine this.
2196            changes.insert(NodeChangeSet::TEXT_CONTENT);
2197            changes.insert(NodeChangeSet::IMAGE_CHANGED);
2198        }
2199
2200        if self.state_hash != other.state_hash {
2201            changes.insert(NodeChangeSet::STYLED_STATE);
2202        }
2203
2204        if self.inline_css_hash != other.inline_css_hash {
2205            // Conservative: inline CSS could affect layout or paint.
2206            // compute_node_changes() checks relayout_scope() per property.
2207            changes.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
2208        }
2209
2210        if self.ids_classes_hash != other.ids_classes_hash {
2211            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
2212        }
2213
2214        if self.callbacks_hash != other.callbacks_hash {
2215            changes.insert(NodeChangeSet::CALLBACKS);
2216        }
2217
2218        if self.attrs_hash != other.attrs_hash {
2219            changes.insert(NodeChangeSet::TAB_INDEX);
2220            changes.insert(NodeChangeSet::CONTENTEDITABLE);
2221        }
2222
2223        if self.dataset_hash != other.dataset_hash {
2224            changes.insert(NodeChangeSet::DATASET);
2225        }
2226
2227        changes
2228    }
2229
2230    /// Returns true if the fingerprint is identical (no changes at all).
2231    #[must_use]
2232    pub fn is_identical(&self, other: &Self) -> bool {
2233        self == other
2234    }
2235
2236    /// Quick check: could this change affect layout?
2237    #[must_use]
2238    pub const fn might_affect_layout(&self, other: &Self) -> bool {
2239        self.content_hash != other.content_hash
2240            || self.inline_css_hash != other.inline_css_hash
2241            || self.ids_classes_hash != other.ids_classes_hash
2242            || self.attrs_hash != other.attrs_hash
2243    }
2244
2245    /// Quick check: could this change affect visuals at all?
2246    #[must_use]
2247    pub const fn might_affect_visuals(&self, other: &Self) -> bool {
2248        self.content_hash != other.content_hash
2249            || self.state_hash != other.state_hash
2250            || self.inline_css_hash != other.inline_css_hash
2251            || self.ids_classes_hash != other.ids_classes_hash
2252    }
2253}
2254
2255// ============================================================================
2256// Pre-cascade DOM fingerprints (two tiers: STRUCTURE vs STYLE)
2257// ============================================================================
2258
2259/// Two-tier fingerprints of a recursive [`crate::dom::Dom`].
2260///
2261/// Computed BEFORE the cascade, in the same pre-order the flattener
2262/// (`convert_dom_into_compact_dom`) assigns `NodeId`s — index `i` in each Vec
2263/// is flattened `NodeId(i)`.
2264///
2265/// WHY TWO TIERS (user directive 2026-08-08): "the start should just scan
2266/// over the `NodeHierarchy` to discover anything that changed, which is
2267/// iterating over a minimal array" — and css must be EXCLUDED from that
2268/// first equivalence, because a stylesheet can only affect the subtree it
2269/// is attached to:
2270///
2271/// - **structure**: hierarchy shape + node content (`node_type`, ids/classes,
2272///   attributes, callback EVENT types). NO css of any kind. If this tier is
2273///   equal, the old tree, its shaped text and its intrinsic caches are all
2274///   reusable — and if the style tier is ALSO equal, the previous CASCADE
2275///   is reusable wholesale (skip `create_from_dom` entirely).
2276/// - **style**: per-node inline css + (at subtree roots that carry
2277///   `.with_css()` sheets) the sheet content. A difference here with an
2278///   equal structure tier means: keep the tree, re-cascade the affected
2279///   subtree(s) only.
2280///
2281/// The per-node arrays exist so a mismatch NAMES the changed nodes (the
2282/// eventual dirty-set for scoped re-cascade / word-granular text relayout);
2283/// the root folds make the equal case one u64 compare per tier.
2284#[derive(Debug, Clone, PartialEq, Eq)]
2285pub struct DomFingerprints {
2286    /// Per-node structural hash, pre-order. Folds: `node_type` content
2287    /// (image-callback nodes hash (fn ptr, `RefAny` `type_id`) — the `RefAny`
2288    /// INSTANCE is rebuilt every frame by design and is transferred, not
2289    /// compared; mirrors `is_layout_equivalent`), ids+classes, callback
2290    /// event types, contenteditable/flags/dataset, and child COUNT (pre-order
2291    /// alone cannot distinguish `[a [b] c]` from `[a [b c]]`).
2292    pub structure: Vec<u64>,
2293    /// Per-node style hash, pre-order: inline css properties + conditions,
2294    /// plus the node's attached `.with_css()` sheets (path, declarations,
2295    /// @-conditions, priority per rule).
2296    pub style: Vec<u64>,
2297    /// Order-sensitive fold of `structure`.
2298    pub structure_root: u64,
2299    /// Order-sensitive fold of `style`.
2300    pub style_root: u64,
2301}
2302
2303/// `RefAny` payloads collected during the fingerprint walk.
2304///
2305/// Transferred onto the retained DOM when the produce side is skipped. The skip path
2306/// keeps last frame's `StyledDom`, but callbacks/image callbacks must use the
2307/// freshly-created `RefAnys` (they may reference new app state) — same
2308/// transfer `regenerate_layout`'s equivalence branch has always done, minus
2309/// the cascade it used to pay to get here. Indices are flattened `NodeIds`.
2310#[derive(Debug, Default, Clone)]
2311pub struct PreCascadeTransfers {
2312    /// `(flattened NodeId index, fresh image callback)` for every
2313    /// `NodeType::Image(DecodedImage::Callback)` node.
2314    pub image_callbacks: Vec<(usize, crate::callbacks::CoreImageCallback)>,
2315    /// `(flattened NodeId index, fresh event callbacks)` for every node with
2316    /// a non-empty callback list.
2317    pub callbacks: Vec<(usize, crate::callbacks::CoreCallbackDataVec)>,
2318    /// `(flattened NodeId index, fresh dataset)` for every node that carries
2319    /// one. Merged onto the retained DOM by [`merge_fresh_dataset`] — the
2320    /// skip path's equivalent of `transfer_states` — so a widget's state
2321    /// survives an identical rebuild and its callbacks (installed from
2322    /// `callbacks` above) end up on the SAME allocation as its dataset.
2323    pub datasets: Vec<(usize, RefAny)>,
2324}
2325
2326/// Walk a recursive [`crate::dom::Dom`] once, pre-order.
2327///
2328/// Produces both fingerprint tiers and the `RefAny` transfer list. Cost: one hash pass over
2329/// node data — no cascade, no allocation proportional to anything but node
2330/// count.
2331#[allow(clippy::too_many_lines)] // cohesive single-pass walker; splitting adds state-threading
2332#[must_use]
2333pub fn fingerprint_dom(dom: &crate::dom::Dom) -> (DomFingerprints, PreCascadeTransfers) {
2334    use core::hash::{Hash, Hasher};
2335
2336    fn node_structure_hash(node: &NodeData, child_count: usize) -> u64 {
2337        use crate::dom::NodeType;
2338        use crate::resources::DecodedImage;
2339        use core::hash::{Hash, Hasher};
2340        let mut h = crate::hash::DefaultHasher::new();
2341
2342        // node_type content — image-callback special case (see struct doc)
2343        match node.get_node_type() {
2344            NodeType::Image(img) => {
2345                match img.get_data() {
2346                    DecodedImage::Callback(cb) => {
2347                        0xB0DE_CA11u32.hash(&mut h);
2348                        cb.callback.cb.hash(&mut h);
2349                        cb.refany.get_type_id().hash(&mut h);
2350                    }
2351                    _ => {
2352                        // Raw / GPU images: ImageRef hashes by id — instance
2353                        // identity, the same strictness is_layout_equivalent's
2354                        // `old_img != new_img` applies.
2355                        node.get_node_type().hash(&mut h);
2356                    }
2357                }
2358            }
2359            other => other.hash(&mut h),
2360        }
2361
2362        // ids + classes (order-sensitive, as worn)
2363        for attr in node.attributes().as_ref() {
2364            match attr {
2365                crate::dom::AttributeType::Id(s) => {
2366                    1u8.hash(&mut h);
2367                    s.hash(&mut h);
2368                }
2369                crate::dom::AttributeType::Class(s) => {
2370                    2u8.hash(&mut h);
2371                    s.hash(&mut h);
2372                }
2373                other => {
2374                    3u8.hash(&mut h);
2375                    other.hash(&mut h);
2376                }
2377            }
2378        }
2379
2380        // callback EVENT types only — the fn ptr + RefAny are transferred,
2381        // not compared (is_layout_equivalent: "compare only event types")
2382        node.callbacks.as_ref().len().hash(&mut h);
2383        for cb in node.callbacks.as_ref() {
2384            cb.event.hash(&mut h);
2385        }
2386
2387        // layout-relevant attributes
2388        node.is_contenteditable().hash(&mut h);
2389        node.flags.hash(&mut h);
2390
2391        // hierarchy shape
2392        child_count.hash(&mut h);
2393
2394        h.finish()
2395    }
2396
2397    fn node_style_hash(dom: &crate::dom::Dom) -> u64 {
2398        use core::hash::{Hash, Hasher};
2399        let mut h = crate::hash::DefaultHasher::new();
2400
2401        for (prop, conds) in dom.root.style.iter_inline_properties() {
2402            prop.hash(&mut h);
2403            conds.as_slice().len().hash(&mut h);
2404        }
2405
2406        // Attached .with_css() sheets — subtree-scoped by construction, so
2407        // they belong to THIS node's style identity.
2408        dom.css.as_ref().len().hash(&mut h);
2409        for css in dom.css.as_ref() {
2410            for rule in css.rules.as_ref() {
2411                rule.path.hash(&mut h);
2412                for decl in rule.declarations.as_ref() {
2413                    decl.hash(&mut h);
2414                }
2415                // DynamicSelector carries f32 media thresholds and derives no
2416                // Hash — the Debug repr is the stable identity here (rare
2417                // path: only @-rule-conditioned blocks have any).
2418                for cond in rule.conditions.as_ref() {
2419                    alloc::format!("{cond:?}").hash(&mut h);
2420                }
2421                rule.priority.hash(&mut h);
2422            }
2423        }
2424
2425        h.finish()
2426    }
2427
2428    fn walk(dom: &crate::dom::Dom, fp: &mut DomFingerprints, transfers: &mut PreCascadeTransfers) {
2429        use crate::dom::NodeType;
2430        use crate::resources::DecodedImage;
2431
2432        let idx = fp.structure.len();
2433        fp.structure
2434            .push(node_structure_hash(&dom.root, dom.children.as_ref().len()));
2435        fp.style.push(node_style_hash(dom));
2436
2437        if let NodeType::Image(img) = dom.root.get_node_type() {
2438            if let DecodedImage::Callback(cb) = img.get_data() {
2439                transfers.image_callbacks.push((idx, cb.clone()));
2440            }
2441        }
2442        if !dom.root.callbacks.as_ref().is_empty() {
2443            transfers.callbacks.push((idx, dom.root.callbacks.clone()));
2444        }
2445        if let Some(ds) = dom.root.get_dataset() {
2446            transfers.datasets.push((idx, ds.clone()));
2447        }
2448
2449        for child in dom.children.as_ref() {
2450            walk(child, fp, transfers);
2451        }
2452    }
2453
2454    let mut fp = DomFingerprints {
2455        structure: Vec::new(),
2456        style: Vec::new(),
2457        structure_root: 0,
2458        style_root: 0,
2459    };
2460    let mut transfers = PreCascadeTransfers::default();
2461    walk(dom, &mut fp, &mut transfers);
2462
2463    let mut hs = crate::hash::DefaultHasher::new();
2464    for v in &fp.structure {
2465        v.hash(&mut hs);
2466    }
2467    fp.structure_root = hs.finish();
2468
2469    let mut hy = crate::hash::DefaultHasher::new();
2470    for v in &fp.style {
2471        v.hash(&mut hy);
2472    }
2473    fp.style_root = hy.finish();
2474
2475    (fp, transfers)
2476}
2477
2478#[cfg(test)]
2479#[path = "diff_test.rs"]
2480mod diff_test;