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::{collections::BTreeMap, collections::VecDeque, string::String, vec::Vec};
16use core::hash::Hash;
17
18use azul_css::props::property::{CssPropertyType, RelayoutScope};
19
20use crate::{
21    dom::{DomId, DomNodeHash, DomNodeId, NodeData, NodeType, IdOrClass},
22    events::{
23        ComponentEventFilter, EventData, EventFilter, EventPhase, EventSource, EventType,
24        LifecycleEventData, LifecycleReason, SyntheticEvent,
25    },
26    geom::LogicalRect,
27    id::NodeId,
28    styled_dom::{ChangedCssProperty, NodeHierarchyItemId, NodeHierarchyItem, RestyleResult, StyledNodeState},
29    task::Instant,
30    OrderedMap,
31};
32
33// ============================================================================
34// NodeChangeSet — granular per-node change flags
35// ============================================================================
36
37/// Bit flags describing what changed about a node between old and new DOM.
38/// Multiple flags can be set simultaneously. Uses manual bit manipulation
39/// instead of bitflags crate to avoid adding a dependency.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub struct NodeChangeSet {
42    pub bits: u32,
43}
44
45impl NodeChangeSet {
46    // --- Changes that affect LAYOUT (need relayout + repaint) ---
47
48    /// Node type changed entirely (e.g., Text → Image).
49    pub const NODE_TYPE_CHANGED: u32    = 0b0000_0000_0000_0001;
50    /// Text content changed (for Text nodes).
51    pub const TEXT_CONTENT: u32         = 0b0000_0000_0000_0010;
52    /// CSS IDs or classes changed (may cause restyle → relayout).
53    pub const IDS_AND_CLASSES: u32      = 0b0000_0000_0000_0100;
54    /// Inline CSS properties changed that affect layout.
55    pub const INLINE_STYLE_LAYOUT: u32  = 0b0000_0000_0000_1000;
56    /// Children added, removed, or reordered.
57    pub const CHILDREN_CHANGED: u32     = 0b0000_0000_0001_0000;
58    /// Image source changed (may affect intrinsic size).
59    pub const IMAGE_CHANGED: u32        = 0b0000_0000_0010_0000;
60    /// Contenteditable flag changed.
61    pub const CONTENTEDITABLE: u32      = 0b0000_0000_0100_0000;
62    /// Tab index changed.
63    pub const TAB_INDEX: u32            = 0b0000_0000_1000_0000;
64
65    // --- Changes that affect PAINT only (no relayout needed) ---
66
67    /// Inline CSS properties changed that affect paint only.
68    pub const INLINE_STYLE_PAINT: u32   = 0b0000_0001_0000_0000;
69    /// Styled node state changed (hover, active, focus, etc.).
70    pub const STYLED_STATE: u32         = 0b0000_0010_0000_0000;
71
72    // --- Changes that affect NEITHER layout nor paint ---
73
74    /// Callbacks changed (new RefAny, different event handlers).
75    pub const CALLBACKS: u32            = 0b0000_0100_0000_0000;
76    /// Dataset changed.
77    pub const DATASET: u32              = 0b0000_1000_0000_0000;
78    /// Accessibility info changed.
79    pub const ACCESSIBILITY: u32        = 0b0001_0000_0000_0000;
80
81    // --- Composite masks ---
82
83    /// Any change that requires a layout pass.
84    pub const AFFECTS_LAYOUT: u32 = Self::NODE_TYPE_CHANGED
85        | Self::TEXT_CONTENT
86        | Self::IDS_AND_CLASSES
87        | Self::INLINE_STYLE_LAYOUT
88        | Self::CHILDREN_CHANGED
89        | Self::IMAGE_CHANGED
90        | Self::CONTENTEDITABLE;
91
92    /// Any change that requires a paint/display-list update (but not layout).
93    pub const AFFECTS_PAINT: u32 = Self::INLINE_STYLE_PAINT
94        | Self::STYLED_STATE;
95
96    pub const fn empty() -> Self {
97        Self { bits: 0 }
98    }
99
100    pub const fn is_empty(&self) -> bool {
101        self.bits == 0
102    }
103
104    pub const fn contains(&self, flag: u32) -> bool {
105        (self.bits & flag) == flag
106    }
107
108    pub const fn intersects(&self, mask: u32) -> bool {
109        (self.bits & mask) != 0
110    }
111
112    pub fn insert(&mut self, flag: u32) {
113        self.bits |= flag;
114    }
115
116    /// Returns true if no visual change occurred (only callbacks/dataset/a11y).
117    pub const fn is_visually_unchanged(&self) -> bool {
118        !self.intersects(Self::AFFECTS_LAYOUT) && !self.intersects(Self::AFFECTS_PAINT)
119    }
120
121    /// Returns true if layout is needed.
122    pub const fn needs_layout(&self) -> bool {
123        self.intersects(Self::AFFECTS_LAYOUT)
124    }
125
126    /// Returns true if paint is needed (but not necessarily layout).
127    pub const fn needs_paint(&self) -> bool {
128        self.intersects(Self::AFFECTS_PAINT)
129    }
130}
131
132impl core::ops::BitOrAssign for NodeChangeSet {
133    fn bitor_assign(&mut self, rhs: Self) {
134        self.bits |= rhs.bits;
135    }
136}
137
138impl core::ops::BitOr for NodeChangeSet {
139    type Output = Self;
140    fn bitor(self, rhs: Self) -> Self {
141        Self { bits: self.bits | rhs.bits }
142    }
143}
144
145/// Extended diff result that includes per-node change information.
146#[derive(Debug, Clone)]
147pub struct ExtendedDiffResult {
148    /// Original diff result (lifecycle events + node moves).
149    pub diff: DiffResult,
150    /// Per-node change report for matched (moved) nodes.
151    /// Each entry: (old_node_id, new_node_id, what_changed).
152    /// Only contains entries for nodes that were matched.
153    pub node_changes: Vec<(NodeId, NodeId, NodeChangeSet)>,
154}
155
156impl Default for ExtendedDiffResult {
157    fn default() -> Self {
158        Self {
159            diff: DiffResult::default(),
160            node_changes: Vec::new(),
161        }
162    }
163}
164
165/// Compare two matched `NodeData` instances field-by-field and return
166/// a `NodeChangeSet` describing what changed.
167pub fn compute_node_changes(
168    old_node: &NodeData,
169    new_node: &NodeData,
170    old_styled_state: Option<&StyledNodeState>,
171    new_styled_state: Option<&StyledNodeState>,
172) -> NodeChangeSet {
173    let mut changes = NodeChangeSet::empty();
174
175    // 1. Node type discriminant
176    if core::mem::discriminant(old_node.get_node_type())
177        != core::mem::discriminant(new_node.get_node_type())
178    {
179        changes.insert(NodeChangeSet::NODE_TYPE_CHANGED);
180        return changes; // everything else is irrelevant
181    }
182
183    // 2. Content-specific comparison (same discriminant)
184    match (old_node.get_node_type(), new_node.get_node_type()) {
185        (NodeType::Text(old_text), NodeType::Text(new_text)) => {
186            if old_text.as_str() != new_text.as_str() {
187                changes.insert(NodeChangeSet::TEXT_CONTENT);
188            }
189        }
190        (NodeType::Image(old_img), NodeType::Image(new_img)) => {
191            // Use Hash-based comparison (pointer identity for decoded images,
192            // callback identity for callback images)
193            use std::hash::Hasher;
194            let hash_img = |img: &crate::resources::ImageRef| -> u64 {
195                let mut h = std::hash::DefaultHasher::new();
196                img.hash(&mut h);
197                h.finish()
198            };
199            if hash_img(old_img) != hash_img(new_img) {
200                changes.insert(NodeChangeSet::IMAGE_CHANGED);
201            }
202        }
203        _ => {} // Same non-content type → no content change
204    }
205
206    // 3. IDs and classes (now stored in attributes as AttributeType::Id/Class)
207    {
208        use crate::dom::AttributeType;
209        let old_ids_classes: Vec<_> = old_node.attributes().as_ref().iter()
210            .filter(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
211            .collect();
212        let new_ids_classes: Vec<_> = new_node.attributes().as_ref().iter()
213            .filter(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
214            .collect();
215        if old_ids_classes != new_ids_classes {
216            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
217        }
218    }
219
220    // 4. Inline CSS properties — classify into layout-affecting vs paint-only.
221    // After the inline-vs-component unification, inline CSS is stored as a `Css`
222    // with rule blocks; iterate it via the `(property, conditions)` flat view to
223    // keep the per-property compare semantics this code was written for.
224    if old_node.style != new_node.style {
225        let mut has_layout = false;
226        let mut has_paint = false;
227
228        // Build a map of property type → (property, conditions) for old props.
229        let mut old_map = OrderedMap::default();
230        for (prop, conds) in old_node.style.iter_inline_properties() {
231            old_map.insert(prop.get_type(), (prop, conds));
232        }
233
234        // Check new props against old.
235        let mut seen_types = OrderedMap::default();
236        for (prop, conds) in new_node.style.iter_inline_properties() {
237            let prop_type = prop.get_type();
238            seen_types.insert(prop_type, ());
239            match old_map.get(&prop_type) {
240                Some(&(old_prop, old_conds))
241                    if old_prop == prop
242                        && old_conds.as_slice() == conds.as_slice() => {} // unchanged
243                _ => {
244                    let scope = prop_type.relayout_scope(true);
245                    if scope != RelayoutScope::None {
246                        has_layout = true;
247                    } else {
248                        has_paint = true;
249                    }
250                }
251            }
252        }
253
254        // Check for removed properties
255        for (prop_type, _) in old_map.iter() {
256            if !seen_types.contains_key(prop_type) {
257                let scope = prop_type.relayout_scope(true);
258                if scope != RelayoutScope::None {
259                    has_layout = true;
260                } else {
261                    has_paint = true;
262                }
263            }
264        }
265
266        if has_layout {
267            changes.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
268        }
269        if has_paint {
270            changes.insert(NodeChangeSet::INLINE_STYLE_PAINT);
271        }
272    }
273
274    // 5. Callbacks
275    {
276        let old_cbs = old_node.callbacks.as_ref();
277        let new_cbs = new_node.callbacks.as_ref();
278        if old_cbs.len() != new_cbs.len() {
279            changes.insert(NodeChangeSet::CALLBACKS);
280        } else {
281            for (o, n) in old_cbs.iter().zip(new_cbs.iter()) {
282                if o.event != n.event || o.callback != n.callback {
283                    changes.insert(NodeChangeSet::CALLBACKS);
284                    break;
285                }
286            }
287        }
288    }
289
290    // 6. Dataset
291    if old_node.get_dataset() != new_node.get_dataset() {
292        changes.insert(NodeChangeSet::DATASET);
293    }
294
295    // 7. Contenteditable
296    if old_node.is_contenteditable() != new_node.is_contenteditable() {
297        changes.insert(NodeChangeSet::CONTENTEDITABLE);
298    }
299
300    // 8. Tab index
301    if old_node.get_tab_index() != new_node.get_tab_index() {
302        changes.insert(NodeChangeSet::TAB_INDEX);
303    }
304
305    // 9. Styled node state (hover, active, focused, etc.)
306    if old_styled_state != new_styled_state {
307        changes.insert(NodeChangeSet::STYLED_STATE);
308    }
309
310    changes
311}
312
313/// Calculate the reconciliation key for a node using the priority hierarchy:
314/// 1. Explicit key (set via `.with_key()`)
315/// 2. CSS ID (set via `.with_id("my-id")`)
316/// 3. Structural key: nth-of-type-within-parent + parent's reconciliation key
317///
318/// The structural key prevents incorrect matching when nodes are inserted
319/// before existing nodes (e.g., prepending items to a list) and allows
320/// keyless nodes to be matched across frames when their logical position
321/// and type are stable (even if content changed — which then fires an
322/// `Update` lifecycle event, see `reconcile_dom`).
323///
324/// When `hierarchy` is empty (or this node has no entry), the structural
325/// key degrades to `discriminant(node_type) + classes` — parent/nth-of-type
326/// context simply drops out. This lets callers that don't track hierarchy
327/// (tests, flat-DOM scenarios) still benefit from explicit-key and CSS-ID
328/// matching without divergent behavior.
329pub fn calculate_reconciliation_key(
330    node_data: &[NodeData],
331    hierarchy: &[NodeHierarchyItem],
332    node_id: NodeId,
333) -> u64 {
334    use core::hash::Hasher;
335
336    let node = &node_data[node_id.index()];
337
338    // Priority 1: Explicit key
339    if let Some(key) = node.get_key() {
340        return key;
341    }
342
343    // Priority 2: CSS ID
344    for attr in node.attributes().as_ref().iter() {
345        if let Some(id) = attr.as_id() {
346            let mut hasher = std::hash::DefaultHasher::new();
347            id.hash(&mut hasher);
348            return hasher.finish();
349        }
350    }
351
352    // Priority 3: Structural key = (node type, classes, nth-of-type, parent key)
353    let mut hasher = std::hash::DefaultHasher::new();
354
355    core::mem::discriminant(node.get_node_type()).hash(&mut hasher);
356    for attr in node.attributes().as_ref().iter() {
357        if let Some(class) = attr.as_class() {
358            class.hash(&mut hasher);
359        }
360    }
361
362    if let Some(hierarchy_item) = hierarchy.get(node_id.index()) {
363        if let Some(parent_id) = hierarchy_item.parent_id() {
364            let mut sibling_index: usize = 0;
365            let parent_hierarchy = &hierarchy[parent_id.index()];
366
367            let mut current = parent_hierarchy.first_child_id(parent_id);
368            while let Some(sibling_id) = current {
369                if sibling_id == node_id {
370                    break;
371                }
372                let sibling = &node_data[sibling_id.index()];
373                if core::mem::discriminant(sibling.get_node_type())
374                    == core::mem::discriminant(node.get_node_type())
375                {
376                    sibling_index += 1;
377                }
378                current = hierarchy[sibling_id.index()].next_sibling_id();
379            }
380
381            sibling_index.hash(&mut hasher);
382
383            let parent_key = calculate_reconciliation_key(node_data, hierarchy, parent_id);
384            parent_key.hash(&mut hasher);
385        }
386    }
387
388    hasher.finish()
389}
390
391/// Precompute reconciliation keys for every node in a DOM tree.
392///
393/// Called once per side (old/new) at the start of `reconcile_dom`. Returns a
394/// vector indexed by node index (`keys[node_id.index()]`) so lookup during
395/// reconciliation is O(1).
396pub fn precompute_reconciliation_keys(
397    node_data: &[NodeData],
398    hierarchy: &[NodeHierarchyItem],
399) -> Vec<u64> {
400    (0..node_data.len())
401        .map(|idx| calculate_reconciliation_key(node_data, hierarchy, NodeId::new(idx)))
402        .collect()
403}
404
405/// Represents a mapping between a node in the old DOM and the new DOM.
406#[derive(Debug, Clone, Copy)]
407pub struct NodeMove {
408    /// The NodeId in the old DOM array
409    pub old_node_id: NodeId,
410    /// The NodeId in the new DOM array
411    pub new_node_id: NodeId,
412}
413
414/// The result of a DOM diff, containing lifecycle events and node mappings.
415#[derive(Debug, Clone)]
416pub struct DiffResult {
417    /// Lifecycle events generated by the diff (Mount, Unmount, Resize, Update)
418    pub events: Vec<SyntheticEvent>,
419    /// Maps Old NodeId -> New NodeId for state migration (focus, scroll, etc.)
420    pub node_moves: Vec<NodeMove>,
421}
422
423impl Default for DiffResult {
424    fn default() -> Self {
425        Self {
426            events: Vec::new(),
427            node_moves: Vec::new(),
428        }
429    }
430}
431
432/// Calculates the difference between two DOM frames and generates lifecycle events.
433///
434/// This is the main entry point for DOM reconciliation. It compares the old and new
435/// DOM trees and produces:
436/// - Mount events for new nodes
437/// - Unmount events for removed nodes
438/// - Resize events for nodes whose bounds changed
439/// - Update events for nodes whose logical position is stable but content changed
440///
441/// # Matching priority
442/// For every node, the reconciliation key (`calculate_reconciliation_key`) encodes
443/// Priority 1 (`.with_key()`), Priority 2 (CSS ID), and Priority 3 (structural key:
444/// nth-of-type + parent key). The tiers are then tried in order:
445///
446/// 1. **Reconciliation key** — matches logical identity, may fire Update on content change.
447/// 2. **Content hash** — exact match including content; catches pure reorders of anonymous nodes.
448/// 3. **Structural hash** — matches node type + attrs ignoring text content; for text-edit cases.
449///
450/// # Arguments
451/// * `old_node_data` / `new_node_data` - Per-node data for each frame
452/// * `old_hierarchy` / `new_hierarchy` - Parent/sibling pointers. Pass `&[]` if unavailable;
453///   the structural-key branch of the reconciliation key degrades gracefully.
454/// * `old_layout` / `new_layout` - Layout bounds used to detect Resize events
455/// * `dom_id` - The DOM identifier
456/// * `timestamp` - Current timestamp for events
457pub fn reconcile_dom(
458    old_node_data: &[NodeData],
459    new_node_data: &[NodeData],
460    old_hierarchy: &[NodeHierarchyItem],
461    new_hierarchy: &[NodeHierarchyItem],
462    old_layout: &OrderedMap<NodeId, LogicalRect>,
463    new_layout: &OrderedMap<NodeId, LogicalRect>,
464    dom_id: DomId,
465    timestamp: Instant,
466) -> DiffResult {
467    let mut result = DiffResult::default();
468
469    // --- STEP 1: INDEX THE OLD DOM ---
470    //
471    // Three tiers, in priority order:
472    //   Tier 1: reconciliation key (.with_key() / CSS ID / structural key)
473    //   Tier 2: content hash (exact node_data hash — matches pure reorders)
474    //   Tier 3: structural hash (discriminant + attrs, ignores text — matches text edits)
475    //
476    // Each tier is keyed with a `VecDeque<NodeId>` because all three can legitimately
477    // collide (two sibling divs produce the same structural key, two identical nodes
478    // produce the same content hash, etc.); we consume in document order on match.
479
480    let old_rec_keys = precompute_reconciliation_keys(old_node_data, old_hierarchy);
481
482    let mut old_by_rec_key: OrderedMap<u64, VecDeque<NodeId>> = OrderedMap::default();
483    let mut old_hashed: OrderedMap<DomNodeHash, VecDeque<NodeId>> = OrderedMap::default();
484    let mut old_structural: OrderedMap<DomNodeHash, VecDeque<NodeId>> = OrderedMap::default();
485    let mut old_nodes_consumed = vec![false; old_node_data.len()];
486
487    for (idx, node) in old_node_data.iter().enumerate() {
488        let id = NodeId::new(idx);
489        old_by_rec_key.entry(old_rec_keys[idx]).or_default().push_back(id);
490
491        let hash = node.calculate_node_data_hash();
492        old_hashed.entry(hash).or_default().push_back(id);
493
494        let structural_hash = node.calculate_structural_hash();
495        old_structural.entry(structural_hash).or_default().push_back(id);
496    }
497
498    // --- STEP 2: ITERATE NEW DOM AND CLAIM MATCHES ---
499
500    // Helper: pop the first non-consumed NodeId from a queue.
501    fn pop_first_unconsumed(
502        queue: &mut VecDeque<NodeId>,
503        consumed: &[bool],
504    ) -> Option<NodeId> {
505        while let Some(&old_id) = queue.front() {
506            if consumed[old_id.index()] {
507                queue.pop_front();
508            } else {
509                queue.pop_front();
510                return Some(old_id);
511            }
512        }
513        None
514    }
515
516    for (new_idx, new_node) in new_node_data.iter().enumerate() {
517        let new_id = NodeId::new(new_idx);
518        let mut matched_old_id = None;
519        let mut matched_by_rec_key = false;
520        let has_explicit_key = new_node.get_key().is_some();
521
522        // Tier 1: Reconciliation key (explicit `.with_key()`, CSS ID, or structural key)
523        let new_rec_key =
524            calculate_reconciliation_key(new_node_data, new_hierarchy, new_id);
525        if let Some(queue) = old_by_rec_key.get_mut(&new_rec_key) {
526            if let Some(old_id) = pop_first_unconsumed(queue, &old_nodes_consumed) {
527                matched_old_id = Some(old_id);
528                matched_by_rec_key = true;
529            }
530        }
531
532        // An explicit `.with_key()` is a strong, intentional identity marker: if it
533        // doesn't match anything in the old DOM we treat the new node as genuinely
534        // new (Mount), rather than falling through to coarser content/structural
535        // tiers and silently matching an unrelated node.
536        if !has_explicit_key && matched_old_id.is_none() {
537            // Tier 2: Content hash (exact match — catches pure reorders)
538            let hash = new_node.calculate_node_data_hash();
539            if let Some(queue) = old_hashed.get_mut(&hash) {
540                if let Some(old_id) = pop_first_unconsumed(queue, &old_nodes_consumed) {
541                    matched_old_id = Some(old_id);
542                }
543            }
544
545            // Tier 3: Structural hash (text-node fallback — ignores text content)
546            if matched_old_id.is_none() {
547                let structural_hash = new_node.calculate_structural_hash();
548                if let Some(queue) = old_structural.get_mut(&structural_hash) {
549                    if let Some(old_id) = pop_first_unconsumed(queue, &old_nodes_consumed) {
550                        matched_old_id = Some(old_id);
551                    }
552                }
553            }
554        }
555
556        // --- STEP 3: PROCESS MATCH OR MOUNT ---
557
558        if let Some(old_id) = matched_old_id {
559            // FOUND A MATCH (It might be at a different index, but it's the "same" node)
560
561            old_nodes_consumed[old_id.index()] = true;
562            result.node_moves.push(NodeMove {
563                old_node_id: old_id,
564                new_node_id: new_id,
565            });
566
567            // Check for Resize
568            let old_rect = old_layout.get(&old_id).copied().unwrap_or(LogicalRect::zero());
569            let new_rect = new_layout.get(&new_id).copied().unwrap_or(LogicalRect::zero());
570
571            if old_rect.size != new_rect.size {
572                // Fire Resize Event
573                if has_resize_callback(new_node) {
574                    result.events.push(create_lifecycle_event(
575                        EventType::Resize,
576                        new_id,
577                        dom_id,
578                        &timestamp,
579                        LifecycleEventData {
580                            reason: LifecycleReason::Resize,
581                            previous_bounds: Some(old_rect),
582                            current_bounds: new_rect,
583                        },
584                    ));
585                }
586            }
587
588            // Fire Update when the node was matched by logical identity (reconciliation
589            // key: explicit .with_key(), CSS ID, or structural key) but its content hash
590            // differs. Tier-2/Tier-3 matches by definition don't carry an Update — a
591            // content-hash match is content-identical, and a structural-hash match is
592            // a text edit handled by cursor/text reconciliation elsewhere.
593            if matched_by_rec_key {
594                let old_hash = old_node_data[old_id.index()].calculate_node_data_hash();
595                let new_hash = new_node.calculate_node_data_hash();
596
597                if old_hash != new_hash && has_update_callback(new_node) {
598                    result.events.push(create_lifecycle_event(
599                        EventType::Update,
600                        new_id,
601                        dom_id,
602                        &timestamp,
603                        LifecycleEventData {
604                            reason: LifecycleReason::Update,
605                            previous_bounds: Some(old_rect),
606                            current_bounds: new_rect,
607                        },
608                    ));
609                }
610            }
611        } else {
612            // NO MATCH FOUND -> MOUNT (New Node)
613            if has_mount_callback(new_node) {
614                let bounds = new_layout.get(&new_id).copied().unwrap_or(LogicalRect::zero());
615                result.events.push(create_lifecycle_event(
616                    EventType::Mount,
617                    new_id,
618                    dom_id,
619                    &timestamp,
620                    LifecycleEventData {
621                        reason: LifecycleReason::InitialMount,
622                        previous_bounds: None,
623                        current_bounds: bounds,
624                    },
625                ));
626            }
627        }
628    }
629
630    // --- STEP 4: CLEANUP (UNMOUNTS) ---
631    // Any old node that wasn't claimed is effectively destroyed.
632
633    for (old_idx, consumed) in old_nodes_consumed.iter().enumerate() {
634        if !consumed {
635            let old_id = NodeId::new(old_idx);
636            let old_node = &old_node_data[old_idx];
637
638            if has_unmount_callback(old_node) {
639                let bounds = old_layout.get(&old_id).copied().unwrap_or(LogicalRect::zero());
640                result.events.push(create_lifecycle_event(
641                    EventType::Unmount,
642                    old_id,
643                    dom_id,
644                    &timestamp,
645                    LifecycleEventData {
646                        reason: LifecycleReason::Unmount,
647                        previous_bounds: Some(bounds),
648                        current_bounds: LogicalRect::zero(),
649                    },
650                ));
651            }
652        }
653    }
654
655    result
656}
657
658/// Creates a lifecycle event with all necessary fields.
659fn create_lifecycle_event(
660    event_type: EventType,
661    node_id: NodeId,
662    dom_id: DomId,
663    timestamp: &Instant,
664    data: LifecycleEventData,
665) -> SyntheticEvent {
666    let dom_node_id = DomNodeId {
667        dom: dom_id,
668        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
669    };
670    SyntheticEvent {
671        event_type,
672        source: EventSource::Lifecycle,
673        phase: EventPhase::Target,
674        target: dom_node_id,
675        current_target: dom_node_id,
676        timestamp: timestamp.clone(),
677        data: EventData::Lifecycle(data),
678        stopped: false,
679        stopped_immediate: false,
680        prevented_default: false,
681    }
682}
683
684/// Check if the node has an AfterMount callback registered.
685fn has_mount_callback(node: &NodeData) -> bool {
686    node.get_callbacks().iter().any(|cb| {
687        matches!(
688            cb.event,
689            EventFilter::Component(ComponentEventFilter::AfterMount)
690        )
691    })
692}
693
694/// Check if the node has a BeforeUnmount callback registered.
695fn has_unmount_callback(node: &NodeData) -> bool {
696    node.get_callbacks().iter().any(|cb| {
697        matches!(
698            cb.event,
699            EventFilter::Component(ComponentEventFilter::BeforeUnmount)
700        )
701    })
702}
703
704/// Check if the node has a NodeResized callback registered.
705fn has_resize_callback(node: &NodeData) -> bool {
706    node.get_callbacks().iter().any(|cb| {
707        matches!(
708            cb.event,
709            EventFilter::Component(ComponentEventFilter::NodeResized)
710        )
711    })
712}
713
714/// Check if the node has any lifecycle callback that would respond to updates.
715fn has_update_callback(node: &NodeData) -> bool {
716    node.get_callbacks().iter().any(|cb| {
717        matches!(
718            cb.event,
719            EventFilter::Component(ComponentEventFilter::Updated)
720        )
721    })
722}
723
724/// Migrate state (focus, scroll, etc.) from old node IDs to new node IDs.
725///
726/// This function should be called after reconciliation to update any state
727/// that references old NodeIds to use the new NodeIds.
728///
729/// # Example
730/// ```rust,ignore
731/// let diff = reconcile_dom(...);
732/// let migration_map = create_migration_map(&diff.node_moves);
733/// 
734/// // Migrate focus
735/// if let Some(current_focus) = focus_manager.focused_node {
736///     if let Some(&new_id) = migration_map.get(&current_focus) {
737///         focus_manager.focused_node = Some(new_id);
738///     } else {
739///         // Focused node was unmounted, clear focus
740///         focus_manager.focused_node = None;
741///     }
742/// }
743/// ```
744pub fn create_migration_map(node_moves: &[NodeMove]) -> OrderedMap<NodeId, NodeId> {
745    let mut map = OrderedMap::default();
746    for m in node_moves {
747        map.insert(m.old_node_id, m.new_node_id);
748    }
749    map
750}
751
752/// Executes state migration between the old DOM and the new DOM based on diff results.
753///
754/// This iterates through matched nodes. If a match has BOTH a merge callback AND a dataset,
755/// it executes the callback to transfer state from the old node to the new node.
756///
757/// This must be called **before** the old DOM is dropped, because we need to access its data.
758///
759/// # Arguments
760/// * `old_node_data` - Mutable reference to the old DOM's node data (source of heavy state)
761/// * `new_node_data` - Mutable reference to the new DOM's node data (target for heavy state)
762/// * `node_moves` - The matched nodes from the reconciliation diff
763///
764/// # Example
765/// ```rust,ignore
766/// let diff_result = reconcile_dom(&old_data, &new_data, ...);
767/// 
768/// // Execute state migration BEFORE old_dom is dropped
769/// transfer_states(&mut old_data, &mut new_data, &diff_result.node_moves);
770/// 
771/// // Now safe to drop old_dom - heavy resources have been transferred
772/// drop(old_dom);
773/// ```
774pub fn transfer_states(
775    old_node_data: &mut [NodeData],
776    new_node_data: &mut [NodeData],
777    node_moves: &[NodeMove],
778) {
779    use crate::refany::OptionRefAny;
780
781    for movement in node_moves {
782        let old_idx = movement.old_node_id.index();
783        let new_idx = movement.new_node_id.index();
784
785        // Bounds check
786        if old_idx >= old_node_data.len() || new_idx >= new_node_data.len() {
787            continue;
788        }
789
790        // 1. Check if the NEW node has requested a merge callback
791        let merge_callback = match new_node_data[new_idx].get_merge_callback() {
792            Some(cb) => cb,
793            None => continue, // No merge callback, skip
794        };
795
796        // 2. Check if BOTH nodes have datasets
797        // We need to temporarily take the datasets to satisfy borrow checker
798        let old_dataset = old_node_data[old_idx].take_dataset();
799        let new_dataset = new_node_data[new_idx].take_dataset();
800
801        match (new_dataset, old_dataset) {
802            (Some(new_data), Some(old_data)) => {
803                // 3. EXECUTE THE MERGE CALLBACK
804                // The callback receives both datasets and returns the merged result
805                let merged = (merge_callback.cb)(new_data, old_data);
806                
807                // 4. Store the merged result back in the new node
808                new_node_data[new_idx].set_dataset(OptionRefAny::Some(merged));
809            }
810            (new_ds, old_ds) => {
811                // One or both datasets missing - restore what we had
812                if let Some(ds) = new_ds {
813                    new_node_data[new_idx].set_dataset(OptionRefAny::Some(ds));
814                }
815                if let Some(ds) = old_ds {
816                    old_node_data[old_idx].set_dataset(OptionRefAny::Some(ds));
817                }
818            }
819        }
820    }
821}
822
823/// Calculate a stable key for a contenteditable node using the hierarchy:
824///
825/// 1. **Explicit Key** - If `.with_key()` was called, use that
826/// 2. **CSS ID** - If the node has a CSS ID (e.g., `#my-editor`), hash that
827/// 3. **Structural Key** - Hash of `(nth-of-type, parent_key)` recursively
828///
829/// The structural key prevents shifting when elements are inserted before siblings.
830/// For example, in `<div><p>A</p><p contenteditable>B</p></div>`, if we insert
831/// a new `<p>` at the start, the contenteditable `<p>` becomes nth-child(3) but
832/// its nth-of-type stays stable (it's still the 2nd `<p>`).
833///
834/// # Arguments
835/// * `node_data` - All nodes in the DOM
836/// * `hierarchy` - Parent-child relationships
837/// * `node_id` - The node to calculate the key for
838///
839/// # Returns
840/// A stable u64 key for the node
841pub fn calculate_contenteditable_key(
842    node_data: &[NodeData],
843    hierarchy: &[crate::styled_dom::NodeHierarchyItem],
844    node_id: NodeId,
845) -> u64 {
846    use std::hash::Hasher;
847    
848    let node = &node_data[node_id.index()];
849    
850    // Priority 1: Explicit key (from .with_key())
851    if let Some(explicit_key) = node.get_key() {
852        return explicit_key;
853    }
854    
855    // Priority 2: CSS ID
856    for attr in node.attributes().as_ref().iter() {
857        if let Some(id) = attr.as_id() {
858            let mut hasher = std::hash::DefaultHasher::new(); // Different seed for ID keys
859            hasher.write(id.as_bytes());
860            return hasher.finish();
861        }
862    }
863    
864    // Priority 3: Structural key = (nth-of-type, classes, parent_key)
865    let mut hasher = std::hash::DefaultHasher::new(); // Different seed for structural keys
866    
867    // Get parent and calculate its key recursively
868    let parent_key = if let Some(parent_id) = hierarchy.get(node_id.index()).and_then(|h| h.parent_id()) {
869        calculate_contenteditable_key(node_data, hierarchy, parent_id)
870    } else {
871        0u64 // Root node
872    };
873    hasher.write(&parent_key.to_le_bytes());
874    
875    // Calculate nth-of-type (count siblings of same node type before this one)
876    // We compare discriminants directly without hashing
877    let node_discriminant = core::mem::discriminant(node.get_node_type());
878    let nth_of_type = if let Some(parent_id) = hierarchy.get(node_id.index()).and_then(|h| h.parent_id()) {
879        // Count siblings with same node type that come before this node
880        let mut count = 0u32;
881        let mut sibling_id = hierarchy.get(parent_id.index()).and_then(|h| h.first_child_id(parent_id));
882        while let Some(sib_id) = sibling_id {
883            if sib_id == node_id {
884                break;
885            }
886            let sibling_discriminant = core::mem::discriminant(node_data[sib_id.index()].get_node_type());
887            if sibling_discriminant == node_discriminant {
888                count += 1;
889            }
890            sibling_id = hierarchy.get(sib_id.index()).and_then(|h| h.next_sibling_id());
891        }
892        count
893    } else {
894        0
895    };
896    
897    hasher.write(&nth_of_type.to_le_bytes());
898    
899    // Hash the node type discriminant (Discriminant<T> implements Hash)
900    node_discriminant.hash(&mut hasher);
901    
902    // Also hash the classes for additional stability
903    for attr in node.attributes().as_ref().iter() {
904        if let Some(class) = attr.as_class() {
905            hasher.write(class.as_bytes());
906        }
907    }
908    
909    hasher.finish()
910}
911
912/// Reconcile cursor byte position when text content changes.
913///
914/// This function maps a cursor position from old text to new text, preserving
915/// the cursor's logical position as much as possible:
916///
917/// 1. If cursor is in unchanged prefix → stays at same byte offset
918/// 2. If cursor is in unchanged suffix → adjusts by length difference
919/// 3. If cursor is in changed region → places at end of new content
920///
921/// # Arguments
922/// * `old_text` - The previous text content
923/// * `new_text` - The new text content
924/// * `old_cursor_byte` - Cursor byte offset in old text
925///
926/// # Returns
927/// The reconciled cursor byte offset in new text
928///
929/// # Example
930/// ```rust,ignore
931/// let old_text = "Hello";
932/// let new_text = "Hello World";
933/// let old_cursor = 5; // cursor at end of "Hello"
934/// let new_cursor = reconcile_cursor_position(old_text, new_text, old_cursor);
935/// assert_eq!(new_cursor, 5); // cursor stays at same position (prefix unchanged)
936/// ```
937pub fn reconcile_cursor_position(
938    old_text: &str,
939    new_text: &str,
940    old_cursor_byte: usize,
941) -> usize {
942    // If texts are equal, cursor is unchanged
943    if old_text == new_text {
944        return old_cursor_byte;
945    }
946    
947    // Empty old text - place cursor at end of new text
948    if old_text.is_empty() {
949        return new_text.len();
950    }
951    
952    // Empty new text - place cursor at 0
953    if new_text.is_empty() {
954        return 0;
955    }
956    
957    // Find common prefix (how many bytes from the start are identical)
958    let common_prefix_bytes = old_text
959        .bytes()
960        .zip(new_text.bytes())
961        .take_while(|(a, b)| a == b)
962        .count();
963    
964    // If cursor was in the unchanged prefix, it stays at the same byte offset
965    if old_cursor_byte <= common_prefix_bytes {
966        return old_cursor_byte.min(new_text.len());
967    }
968    
969    // Find common suffix (how many bytes from the end are identical)
970    let common_suffix_bytes = old_text
971        .bytes()
972        .rev()
973        .zip(new_text.bytes().rev())
974        .take_while(|(a, b)| a == b)
975        .count();
976    
977    // Calculate where the suffix starts in old and new text
978    let old_suffix_start = old_text.len().saturating_sub(common_suffix_bytes);
979    let new_suffix_start = new_text.len().saturating_sub(common_suffix_bytes);
980    
981    // If cursor was in the unchanged suffix, adjust by length difference
982    if old_cursor_byte >= old_suffix_start {
983        let offset_from_end = old_text.len() - old_cursor_byte;
984        return new_text.len().saturating_sub(offset_from_end);
985    }
986    
987    // Cursor was in the changed region - place at end of inserted content
988    // This handles insertions (cursor moves with new text) and deletions (cursor at edit point)
989    new_suffix_start
990}
991
992/// Get the text content from a NodeData if it's a Text node.
993///
994/// Returns the text string if the node is `NodeType::Text`, otherwise `None`.
995pub fn get_node_text_content(node: &NodeData) -> Option<&str> {
996    if let crate::dom::NodeType::Text(ref text) = node.get_node_type() {
997        Some(text.as_str())
998    } else {
999        None
1000    }
1001}
1002
1003// ============================================================================
1004// ChangeAccumulator — unifies all change input paths
1005// ============================================================================
1006
1007/// Text change info for cursor/selection reconciliation.
1008#[derive(Debug, Clone, PartialEq, Eq)]
1009pub struct TextChange {
1010    /// The text content before the change.
1011    pub old_text: String,
1012    /// The text content after the change.
1013    pub new_text: String,
1014}
1015
1016/// Per-node change report combining multiple information sources.
1017#[derive(Debug, Clone, Default)]
1018pub struct NodeChangeReport {
1019    /// Bitflags from DOM-level field comparison.
1020    pub change_set: NodeChangeSet,
1021
1022    /// Highest RelayoutScope from any CSS property that changed on this node.
1023    /// This is more granular than NodeChangeSet's binary LAYOUT/PAINT split.
1024    ///
1025    /// - `None` → repaint only (color, opacity, transform)
1026    /// - `IfcOnly` → reshape text in the containing IFC
1027    /// - `SizingOnly` → recompute this node's intrinsic size
1028    /// - `Full` → full subtree relayout (display, position, float, etc.)
1029    pub relayout_scope: RelayoutScope,
1030
1031    /// Individual CSS properties that changed (for fine-grained cache invalidation).
1032    /// Empty if the change was structural (text content, node type, etc.)
1033    pub changed_css_properties: Vec<CssPropertyType>,
1034
1035    /// If text content changed, the old and new text for cursor reconciliation.
1036    pub text_change: Option<TextChange>,
1037}
1038
1039impl NodeChangeReport {
1040    /// Returns the DirtyFlag level needed for this change report.
1041    /// Maps RelayoutScope + NodeChangeSet → a simple tri-state.
1042    pub fn needs_layout(&self) -> bool {
1043        self.change_set.needs_layout() || self.relayout_scope > RelayoutScope::None
1044    }
1045
1046    pub fn needs_paint(&self) -> bool {
1047        self.change_set.needs_paint()
1048    }
1049
1050    pub fn is_visually_unchanged(&self) -> bool {
1051        self.change_set.is_visually_unchanged() && self.relayout_scope == RelayoutScope::None
1052    }
1053}
1054
1055/// Unified change report that merges information from all three change paths:
1056///
1057/// 1. **DOM reconciliation** (`compute_node_changes` after `reconcile_dom`)
1058/// 2. **CSS restyle** (`restyle_on_state_change` for hover/focus/active)
1059/// 3. **Runtime edits** (`words_changed`, `css_properties_changed`, `images_changed`)
1060///
1061/// This is the single source of truth for "what work needs to happen this frame".
1062#[derive(Debug, Clone, Default)]
1063pub struct ChangeAccumulator {
1064    /// Per-node change info. Key is the new-DOM NodeId.
1065    pub per_node: BTreeMap<NodeId, NodeChangeReport>,
1066
1067    /// Maximum RelayoutScope across all changed nodes.
1068    /// Quick check: if this is `None`, we can skip layout entirely.
1069    pub max_scope: RelayoutScope,
1070
1071    /// Nodes that are newly mounted (no old counterpart).
1072    /// These always need full layout.
1073    pub mounted_nodes: Vec<NodeId>,
1074
1075    /// Nodes that were unmounted (no new counterpart).
1076    /// Used for cleanup (remove from scroll/focus/cursor managers).
1077    pub unmounted_nodes: Vec<NodeId>,
1078}
1079
1080impl ChangeAccumulator {
1081    pub fn new() -> Self {
1082        Self::default()
1083    }
1084
1085    /// Returns true if no changes were detected at all.
1086    pub fn is_empty(&self) -> bool {
1087        self.per_node.is_empty() && self.mounted_nodes.is_empty() && self.unmounted_nodes.is_empty()
1088    }
1089
1090    /// Returns true if layout work is needed (any node has scope > None).
1091    pub fn needs_layout(&self) -> bool {
1092        self.max_scope > RelayoutScope::None
1093            || !self.mounted_nodes.is_empty()
1094            || self.per_node.values().any(|r| r.needs_layout())
1095    }
1096
1097    /// Returns true if only paint work is needed (no layout).
1098    pub fn needs_paint_only(&self) -> bool {
1099        !self.needs_layout() && self.per_node.values().any(|r| r.needs_paint())
1100    }
1101
1102    /// Returns true if only non-visual changes occurred (callbacks, dataset, a11y).
1103    pub fn is_visually_unchanged(&self) -> bool {
1104        self.mounted_nodes.is_empty()
1105            && self.unmounted_nodes.is_empty()
1106            && self.max_scope == RelayoutScope::None
1107            && self.per_node.values().all(|r| r.is_visually_unchanged())
1108    }
1109
1110    /// Add a node change from DOM reconciliation (Path A).
1111    pub fn add_dom_change(
1112        &mut self,
1113        new_node_id: NodeId,
1114        change_set: NodeChangeSet,
1115        relayout_scope: RelayoutScope,
1116        text_change: Option<TextChange>,
1117        changed_css_properties: Vec<CssPropertyType>,
1118    ) {
1119        if relayout_scope > self.max_scope {
1120            self.max_scope = relayout_scope;
1121        }
1122
1123        let report = self.per_node.entry(new_node_id).or_default();
1124        report.change_set |= change_set;
1125        if relayout_scope > report.relayout_scope {
1126            report.relayout_scope = relayout_scope;
1127        }
1128        if text_change.is_some() {
1129            report.text_change = text_change;
1130        }
1131        report.changed_css_properties.extend(changed_css_properties);
1132    }
1133
1134    /// Add a text change (from runtime edit or DOM reconciliation).
1135    pub fn add_text_change(
1136        &mut self,
1137        node_id: NodeId,
1138        old_text: String,
1139        new_text: String,
1140    ) {
1141        let scope = RelayoutScope::IfcOnly;
1142        if scope > self.max_scope {
1143            self.max_scope = scope;
1144        }
1145
1146        let report = self.per_node.entry(node_id).or_default();
1147        report.change_set.insert(NodeChangeSet::TEXT_CONTENT);
1148        if scope > report.relayout_scope {
1149            report.relayout_scope = scope;
1150        }
1151        report.text_change = Some(TextChange { old_text, new_text });
1152    }
1153
1154    /// Add a CSS property change (from runtime edit or restyle).
1155    pub fn add_css_change(
1156        &mut self,
1157        node_id: NodeId,
1158        prop_type: CssPropertyType,
1159        scope: RelayoutScope,
1160    ) {
1161        if scope > self.max_scope {
1162            self.max_scope = scope;
1163        }
1164
1165        let report = self.per_node.entry(node_id).or_default();
1166        if scope > RelayoutScope::None {
1167            report.change_set.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
1168        } else {
1169            report.change_set.insert(NodeChangeSet::INLINE_STYLE_PAINT);
1170        }
1171        if scope > report.relayout_scope {
1172            report.relayout_scope = scope;
1173        }
1174        report.changed_css_properties.push(prop_type);
1175    }
1176
1177    /// Add an image change (from runtime edit or DOM reconciliation).
1178    pub fn add_image_change(
1179        &mut self,
1180        node_id: NodeId,
1181        scope: RelayoutScope,
1182    ) {
1183        if scope > self.max_scope {
1184            self.max_scope = scope;
1185        }
1186
1187        let report = self.per_node.entry(node_id).or_default();
1188        report.change_set.insert(NodeChangeSet::IMAGE_CHANGED);
1189        if scope > report.relayout_scope {
1190            report.relayout_scope = scope;
1191        }
1192    }
1193
1194    /// Add a mounted (new) node.
1195    pub fn add_mount(&mut self, node_id: NodeId) {
1196        self.mounted_nodes.push(node_id);
1197    }
1198
1199    /// Add an unmounted (removed) node.
1200    pub fn add_unmount(&mut self, node_id: NodeId) {
1201        self.unmounted_nodes.push(node_id);
1202    }
1203
1204    /// Merge a `RestyleResult` (from `restyle_on_state_change()`) into this accumulator.
1205    ///
1206    /// This is the bridge between Path B (restyle) and the unified change pipeline.
1207    /// Each `ChangedCssProperty` is classified via `relayout_scope()` to determine
1208    /// whether it affects layout or only paint.
1209    pub fn merge_restyle_result(&mut self, restyle: &crate::styled_dom::RestyleResult) {
1210        for (node_id, changed_props) in &restyle.changed_nodes {
1211            for changed in changed_props {
1212                let prop_type = changed.current_prop.get_type();
1213                let scope = prop_type.relayout_scope(true); // conservative
1214                self.add_css_change(*node_id, prop_type, scope);
1215            }
1216        }
1217    }
1218
1219    /// Populate this accumulator from an `ExtendedDiffResult` + the old/new DOM data.
1220    ///
1221    /// This converts per-node `NodeChangeSet` flags into full `NodeChangeReport`s
1222    /// with `RelayoutScope` classification.
1223    pub fn merge_extended_diff(
1224        &mut self,
1225        extended: &ExtendedDiffResult,
1226        old_node_data: &[NodeData],
1227        new_node_data: &[NodeData],
1228    ) {
1229        for &(old_id, new_id, ref change_set) in &extended.node_changes {
1230            if change_set.is_empty() {
1231                continue;
1232            }
1233
1234            // Determine RelayoutScope from the change flags
1235            let scope = self.classify_change_scope(change_set, new_node_data, new_id);
1236
1237            // Extract text change info if TEXT_CONTENT flag is set
1238            let text_change = if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
1239                let old_text = get_node_text_content(&old_node_data[old_id.index()])
1240                    .unwrap_or("")
1241                    .to_string();
1242                let new_text = get_node_text_content(&new_node_data[new_id.index()])
1243                    .unwrap_or("")
1244                    .to_string();
1245                Some(TextChange { old_text, new_text })
1246            } else {
1247                None
1248            };
1249
1250            self.add_dom_change(new_id, *change_set, scope, text_change, Vec::new());
1251        }
1252
1253        // Track mounts: new nodes that didn't match anything in old
1254        let matched_new: alloc::collections::BTreeSet<usize> = extended
1255            .diff
1256            .node_moves
1257            .iter()
1258            .map(|m| m.new_node_id.index())
1259            .collect();
1260
1261        for idx in 0..new_node_data.len() {
1262            if !matched_new.contains(&idx) {
1263                self.add_mount(NodeId::new(idx));
1264            }
1265        }
1266
1267        // Track unmounts: old nodes that didn't match anything in new
1268        let matched_old: alloc::collections::BTreeSet<usize> = extended
1269            .diff
1270            .node_moves
1271            .iter()
1272            .map(|m| m.old_node_id.index())
1273            .collect();
1274
1275        for idx in 0..old_node_data.len() {
1276            if !matched_old.contains(&idx) {
1277                self.add_unmount(NodeId::new(idx));
1278            }
1279        }
1280    }
1281
1282    /// Classify a NodeChangeSet into the appropriate RelayoutScope.
1283    fn classify_change_scope(
1284        &self,
1285        change_set: &NodeChangeSet,
1286        new_node_data: &[NodeData],
1287        new_node_id: NodeId,
1288    ) -> RelayoutScope {
1289        // NODE_TYPE_CHANGED or CHILDREN_CHANGED → Full
1290        if change_set.contains(NodeChangeSet::NODE_TYPE_CHANGED)
1291            || change_set.contains(NodeChangeSet::CHILDREN_CHANGED)
1292        {
1293            return RelayoutScope::Full;
1294        }
1295
1296        // IDS_AND_CLASSES → Full (conservative: class change may add layout-affecting CSS)
1297        if change_set.contains(NodeChangeSet::IDS_AND_CLASSES) {
1298            return RelayoutScope::Full;
1299        }
1300
1301        // INLINE_STYLE_LAYOUT → could be IfcOnly, SizingOnly, or Full
1302        // We need to check individual properties for the exact scope.
1303        // For now, we use SizingOnly as a conservative default since
1304        // the individual property scopes were already checked in compute_node_changes.
1305        if change_set.contains(NodeChangeSet::INLINE_STYLE_LAYOUT) {
1306            // Walk the inline CSS properties to find the max scope
1307            let new_node = &new_node_data[new_node_id.index()];
1308            let mut max_scope = RelayoutScope::None;
1309            for (prop, _conds) in new_node.style.iter_inline_properties() {
1310                let scope = prop.get_type().relayout_scope(true);
1311                if scope > max_scope {
1312                    max_scope = scope;
1313                }
1314            }
1315            return if max_scope == RelayoutScope::None {
1316                RelayoutScope::SizingOnly // conservative fallback
1317            } else {
1318                max_scope
1319            };
1320        }
1321
1322        // TEXT_CONTENT → IfcOnly (reshape text, may cascade)
1323        if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
1324            return RelayoutScope::IfcOnly;
1325        }
1326
1327        // IMAGE_CHANGED → SizingOnly (intrinsic size may change)
1328        if change_set.contains(NodeChangeSet::IMAGE_CHANGED) {
1329            return RelayoutScope::SizingOnly;
1330        }
1331
1332        // CONTENTEDITABLE → SizingOnly
1333        if change_set.contains(NodeChangeSet::CONTENTEDITABLE) {
1334            return RelayoutScope::SizingOnly;
1335        }
1336
1337        // Paint-only or no-visual changes
1338        if change_set.intersects(NodeChangeSet::AFFECTS_PAINT) {
1339            return RelayoutScope::None;
1340        }
1341
1342        RelayoutScope::None
1343    }
1344}
1345
1346/// Perform a full reconciliation with change detection.
1347///
1348/// This combines `reconcile_dom()` + `compute_node_changes()` into a single
1349/// pass that produces an `ExtendedDiffResult` with per-node change flags.
1350///
1351/// The `ChangeAccumulator` can then be populated from the result via
1352/// `accumulator.merge_extended_diff()`.
1353pub fn reconcile_dom_with_changes(
1354    old_node_data: &[NodeData],
1355    new_node_data: &[NodeData],
1356    old_hierarchy: &[NodeHierarchyItem],
1357    new_hierarchy: &[NodeHierarchyItem],
1358    old_styled_nodes: Option<&[StyledNodeState]>,
1359    new_styled_nodes: Option<&[StyledNodeState]>,
1360    old_layout: &OrderedMap<NodeId, LogicalRect>,
1361    new_layout: &OrderedMap<NodeId, LogicalRect>,
1362    dom_id: DomId,
1363    timestamp: Instant,
1364) -> ExtendedDiffResult {
1365    // Step 1: Run standard reconciliation
1366    let diff = reconcile_dom(
1367        old_node_data,
1368        new_node_data,
1369        old_hierarchy,
1370        new_hierarchy,
1371        old_layout,
1372        new_layout,
1373        dom_id,
1374        timestamp,
1375    );
1376
1377    // Step 2: For each matched pair, compute what changed
1378    let mut node_changes = Vec::new();
1379    for node_move in &diff.node_moves {
1380        let old_nd = &old_node_data[node_move.old_node_id.index()];
1381        let new_nd = &new_node_data[node_move.new_node_id.index()];
1382
1383        let old_state = old_styled_nodes.and_then(|s| s.get(node_move.old_node_id.index()));
1384        let new_state = new_styled_nodes.and_then(|s| s.get(node_move.new_node_id.index()));
1385
1386        let changes = compute_node_changes(old_nd, new_nd, old_state, new_state);
1387        node_changes.push((node_move.old_node_id, node_move.new_node_id, changes));
1388    }
1389
1390    ExtendedDiffResult { diff, node_changes }
1391}
1392
1393// ============================================================================
1394// NodeDataFingerprint — multi-field hash for fast change detection
1395// ============================================================================
1396
1397/// Per-node hash broken into independent fields for fast change detection.
1398///
1399/// Instead of a single u64 hash (which loses all granularity), this stores
1400/// separate hashes per field category. Comparing two fingerprints is O(1)
1401/// (6 integer comparisons) and immediately tells us WHICH category changed,
1402/// avoiding the more expensive `compute_node_changes()` for unchanged nodes.
1403///
1404/// Two-tier strategy:
1405/// - **Tier 1** (this struct): O(1) per node, identifies which categories changed.
1406/// - **Tier 2** (`compute_node_changes`): O(n) per changed field, does field-by-field
1407///   comparison only for nodes that Tier 1 identified as changed.
1408#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1409pub struct NodeDataFingerprint {
1410    /// Hash of node_type (Text content, Image ref, Div, etc.)
1411    pub content_hash: u64,
1412    /// Hash of styled_node_state (hover, focus, active bits)
1413    pub state_hash: u64,
1414    /// Hash of inline CSS properties
1415    pub inline_css_hash: u64,
1416    /// Hash of ids_and_classes
1417    pub ids_classes_hash: u64,
1418    /// Hash of callbacks (event types + function pointers)
1419    pub callbacks_hash: u64,
1420    /// Hash of other attributes (contenteditable, tab_index, dataset)
1421    pub attrs_hash: u64,
1422}
1423
1424impl Default for NodeDataFingerprint {
1425    fn default() -> Self {
1426        Self {
1427            content_hash: 0,
1428            state_hash: 0,
1429            inline_css_hash: 0,
1430            ids_classes_hash: 0,
1431            callbacks_hash: 0,
1432            attrs_hash: 0,
1433        }
1434    }
1435}
1436
1437impl NodeDataFingerprint {
1438    /// Compute a fingerprint from a node's data and styled state.
1439    pub fn compute(node: &NodeData, styled_state: Option<&StyledNodeState>) -> Self {
1440        use std::hash::Hasher;
1441        use core::hash::Hash;
1442
1443        // Content hash
1444        let content_hash = {
1445            let mut h = std::hash::DefaultHasher::new();
1446            node.get_node_type().hash(&mut h);
1447            h.finish()
1448        };
1449
1450        // State hash
1451        let state_hash = {
1452            let mut h = std::hash::DefaultHasher::new();
1453            if let Some(state) = styled_state {
1454                state.hash(&mut h);
1455            }
1456            h.finish()
1457        };
1458
1459        // Inline CSS hash — full CssProperty value (matches the legacy
1460        // CssPropertyWithConditions::hash that hashed both property and the
1461        // condition vec length).
1462        let inline_css_hash = {
1463            let mut h = std::hash::DefaultHasher::new();
1464            for (prop, conds) in node.style.iter_inline_properties() {
1465                prop.hash(&mut h);
1466                conds.as_slice().len().hash(&mut h);
1467            }
1468            h.finish()
1469        };
1470
1471        // IDs and classes hash (now stored in attributes)
1472        let ids_classes_hash = {
1473            let mut h = std::hash::DefaultHasher::new();
1474            for attr in node.attributes().as_ref().iter() {
1475                match attr {
1476                    crate::dom::AttributeType::Id(s) => {
1477                        crate::dom::IdOrClass::Id(s.clone()).hash(&mut h);
1478                    }
1479                    crate::dom::AttributeType::Class(s) => {
1480                        crate::dom::IdOrClass::Class(s.clone()).hash(&mut h);
1481                    }
1482                    _ => {}
1483                }
1484            }
1485            h.finish()
1486        };
1487
1488        // Callbacks hash
1489        let callbacks_hash = {
1490            let mut h = std::hash::DefaultHasher::new();
1491            for cb in node.callbacks.as_ref().iter() {
1492                cb.event.hash(&mut h);
1493                cb.callback.hash(&mut h);
1494            }
1495            h.finish()
1496        };
1497
1498        // Attributes hash
1499        let attrs_hash = {
1500            let mut h = std::hash::DefaultHasher::new();
1501            node.is_contenteditable().hash(&mut h);
1502            node.flags.hash(&mut h);
1503            node.get_dataset().hash(&mut h);
1504            h.finish()
1505        };
1506
1507        Self {
1508            content_hash,
1509            state_hash,
1510            inline_css_hash,
1511            ids_classes_hash,
1512            callbacks_hash,
1513            attrs_hash,
1514        }
1515    }
1516
1517    /// Returns a quick NodeChangeSet by comparing two fingerprints.
1518    /// This is O(1) — just comparing 6 u64s.
1519    ///
1520    /// The result is *conservative*: if a field hash differs, we set the
1521    /// broadest applicable flag. For precise classification (e.g., which
1522    /// CSS properties changed and their `relayout_scope()`), the caller
1523    /// should fall back to `compute_node_changes()` for changed nodes.
1524    pub fn diff(&self, other: &NodeDataFingerprint) -> NodeChangeSet {
1525        let mut changes = NodeChangeSet::empty();
1526
1527        if self.content_hash != other.content_hash {
1528            // Could be TEXT_CONTENT, IMAGE_CHANGED, or NODE_TYPE_CHANGED
1529            // We set both TEXT_CONTENT and IMAGE_CHANGED conservatively;
1530            // compute_node_changes() will refine this.
1531            changes.insert(NodeChangeSet::TEXT_CONTENT);
1532            changes.insert(NodeChangeSet::IMAGE_CHANGED);
1533        }
1534
1535        if self.state_hash != other.state_hash {
1536            changes.insert(NodeChangeSet::STYLED_STATE);
1537        }
1538
1539        if self.inline_css_hash != other.inline_css_hash {
1540            // Conservative: inline CSS could affect layout or paint.
1541            // compute_node_changes() checks relayout_scope() per property.
1542            changes.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
1543        }
1544
1545        if self.ids_classes_hash != other.ids_classes_hash {
1546            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
1547        }
1548
1549        if self.callbacks_hash != other.callbacks_hash {
1550            changes.insert(NodeChangeSet::CALLBACKS);
1551        }
1552
1553        if self.attrs_hash != other.attrs_hash {
1554            changes.insert(NodeChangeSet::TAB_INDEX);
1555            changes.insert(NodeChangeSet::CONTENTEDITABLE);
1556        }
1557
1558        changes
1559    }
1560
1561    /// Returns true if the fingerprint is identical (no changes at all).
1562    pub fn is_identical(&self, other: &NodeDataFingerprint) -> bool {
1563        self == other
1564    }
1565
1566    /// Quick check: could this change affect layout?
1567    pub fn might_affect_layout(&self, other: &NodeDataFingerprint) -> bool {
1568        self.content_hash != other.content_hash
1569            || self.inline_css_hash != other.inline_css_hash
1570            || self.ids_classes_hash != other.ids_classes_hash
1571            || self.attrs_hash != other.attrs_hash
1572    }
1573
1574    /// Quick check: could this change affect visuals at all?
1575    pub fn might_affect_visuals(&self, other: &NodeDataFingerprint) -> bool {
1576        self.content_hash != other.content_hash
1577            || self.state_hash != other.state_hash
1578            || self.inline_css_hash != other.inline_css_hash
1579            || self.ids_classes_hash != other.ids_classes_hash
1580    }
1581}