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

            
15
use alloc::{collections::BTreeMap, collections::VecDeque, string::{String, ToString}, vec::Vec};
16
use core::hash::Hash;
17

            
18
use azul_css::props::property::{CssPropertyType, RelayoutScope};
19

            
20
use 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)]
41
pub struct NodeChangeSet {
42
    pub bits: u32,
43
}
44

            
45
impl 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
1155
    pub const fn empty() -> Self {
97
1155
        Self { bits: 0 }
98
1155
    }
99

            
100
340
    pub const fn is_empty(&self) -> bool {
101
340
        self.bits == 0
102
340
    }
103

            
104
1581
    pub const fn contains(&self, flag: u32) -> bool {
105
1581
        (self.bits & flag) == flag
106
1581
    }
107

            
108
984
    pub const fn intersects(&self, mask: u32) -> bool {
109
984
        (self.bits & mask) != 0
110
984
    }
111

            
112
1462
    pub fn insert(&mut self, flag: u32) {
113
1462
        self.bits |= flag;
114
1462
    }
115

            
116
    /// Returns true if no visual change occurred (only callbacks/dataset/a11y).
117
102
    pub const fn is_visually_unchanged(&self) -> bool {
118
102
        !self.intersects(Self::AFFECTS_LAYOUT) && !self.intersects(Self::AFFECTS_PAINT)
119
102
    }
120

            
121
    /// Returns true if layout is needed.
122
509
    pub const fn needs_layout(&self) -> bool {
123
509
        self.intersects(Self::AFFECTS_LAYOUT)
124
509
    }
125

            
126
    /// Returns true if paint is needed (but not necessarily layout).
127
271
    pub const fn needs_paint(&self) -> bool {
128
271
        self.intersects(Self::AFFECTS_PAINT)
129
271
    }
130
}
131

            
132
impl core::ops::BitOrAssign for NodeChangeSet {
133
136
    fn bitor_assign(&mut self, rhs: Self) {
134
136
        self.bits |= rhs.bits;
135
136
    }
136
}
137

            
138
impl 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)]
147
pub 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

            
156
impl 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.
167
884
pub fn compute_node_changes(
168
884
    old_node: &NodeData,
169
884
    new_node: &NodeData,
170
884
    old_styled_state: Option<&StyledNodeState>,
171
884
    new_styled_state: Option<&StyledNodeState>,
172
884
) -> NodeChangeSet {
173
884
    let mut changes = NodeChangeSet::empty();
174

            
175
    // 1. Node type discriminant
176
884
    if core::mem::discriminant(old_node.get_node_type())
177
884
        != core::mem::discriminant(new_node.get_node_type())
178
    {
179
68
        changes.insert(NodeChangeSet::NODE_TYPE_CHANGED);
180
68
        return changes; // everything else is irrelevant
181
816
    }
182

            
183
    // 2. Content-specific comparison (same discriminant)
184
816
    match (old_node.get_node_type(), new_node.get_node_type()) {
185
425
        (NodeType::Text(old_text), NodeType::Text(new_text)) => {
186
425
            if old_text.as_str() != new_text.as_str() {
187
289
                changes.insert(NodeChangeSet::TEXT_CONTENT);
188
289
            }
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 core::hash::Hasher;
194
            let hash_img = |img: &crate::resources::ImageRef| -> u64 {
195
                let mut h = crate::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
391
        _ => {} // 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
816
        let old_ids_classes: Vec<_> = old_node.attributes().as_ref().iter()
210
816
            .filter(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
211
816
            .collect();
212
816
        let new_ids_classes: Vec<_> = new_node.attributes().as_ref().iter()
213
816
            .filter(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
214
816
            .collect();
215
816
        if old_ids_classes != new_ids_classes {
216
85
            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
217
731
        }
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
816
    if old_node.style != new_node.style {
225
102
        let mut has_layout = false;
226
102
        let mut has_paint = false;
227

            
228
        // Build a map of property type → (property, conditions) for old props.
229
102
        let mut old_map = OrderedMap::default();
230
102
        for (prop, conds) in old_node.style.iter_inline_properties() {
231
51
            old_map.insert(prop.get_type(), (prop, conds));
232
51
        }
233

            
234
        // Check new props against old.
235
102
        let mut seen_types = OrderedMap::default();
236
102
        for (prop, conds) in new_node.style.iter_inline_properties() {
237
102
            let prop_type = prop.get_type();
238
102
            seen_types.insert(prop_type, ());
239
102
            match old_map.get(&prop_type) {
240
                Some(&(old_prop, old_conds))
241
51
                    if old_prop == prop
242
                        && old_conds.as_slice() == conds.as_slice() => {} // unchanged
243
                _ => {
244
102
                    let scope = prop_type.relayout_scope(true);
245
102
                    if scope != RelayoutScope::None {
246
68
                        has_layout = true;
247
68
                    } else {
248
34
                        has_paint = true;
249
34
                    }
250
                }
251
            }
252
        }
253

            
254
        // Check for removed properties
255
102
        for (prop_type, _) in old_map.iter() {
256
51
            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
51
            }
264
        }
265

            
266
102
        if has_layout {
267
68
            changes.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
268
68
        }
269
102
        if has_paint {
270
34
            changes.insert(NodeChangeSet::INLINE_STYLE_PAINT);
271
68
        }
272
714
    }
273

            
274
    // 5. Callbacks
275
    {
276
816
        let old_cbs = old_node.callbacks.as_ref();
277
816
        let new_cbs = new_node.callbacks.as_ref();
278
816
        if old_cbs.len() != new_cbs.len() {
279
            changes.insert(NodeChangeSet::CALLBACKS);
280
        } else {
281
816
            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
816
    if old_node.get_dataset() != new_node.get_dataset() {
292
        changes.insert(NodeChangeSet::DATASET);
293
816
    }
294

            
295
    // 7. Contenteditable
296
816
    if old_node.is_contenteditable() != new_node.is_contenteditable() {
297
34
        changes.insert(NodeChangeSet::CONTENTEDITABLE);
298
782
    }
299

            
300
    // 8. Tab index
301
816
    if old_node.get_tab_index() != new_node.get_tab_index() {
302
17
        changes.insert(NodeChangeSet::TAB_INDEX);
303
799
    }
304

            
305
    // 9. Styled node state (hover, active, focused, etc.)
306
816
    if old_styled_state != new_styled_state {
307
34
        changes.insert(NodeChangeSet::STYLED_STATE);
308
782
    }
309

            
310
816
    changes
311
884
}
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.
329
44693
pub fn calculate_reconciliation_key(
330
44693
    node_data: &[NodeData],
331
44693
    hierarchy: &[NodeHierarchyItem],
332
44693
    node_id: NodeId,
333
44693
) -> u64 {
334
    use core::hash::Hasher;
335

            
336
44693
    let node = &node_data[node_id.index()];
337

            
338
    // Priority 1: Explicit key
339
44693
    if let Some(key) = node.get_key() {
340
289
        return key;
341
44404
    }
342

            
343
    // Priority 2: CSS ID
344
44404
    for attr in node.attributes().as_ref().iter() {
345
38386
        if let Some(id) = attr.as_id() {
346
34272
            let mut hasher = crate::hash::DefaultHasher::new();
347
34272
            id.hash(&mut hasher);
348
34272
            return hasher.finish();
349
4114
        }
350
    }
351

            
352
    // Priority 3: Structural key = (node type, classes, nth-of-type, parent key)
353
10132
    let mut hasher = crate::hash::DefaultHasher::new();
354

            
355
10132
    core::mem::discriminant(node.get_node_type()).hash(&mut hasher);
356
10132
    for attr in node.attributes().as_ref().iter() {
357
4114
        if let Some(class) = attr.as_class() {
358
4114
            class.hash(&mut hasher);
359
4114
        }
360
    }
361

            
362
10132
    if let Some(hierarchy_item) = hierarchy.get(node_id.index()) {
363
867
        if let Some(parent_id) = hierarchy_item.parent_id() {
364
374
            let mut sibling_index: usize = 0;
365
374
            let parent_hierarchy = &hierarchy[parent_id.index()];
366

            
367
374
            let mut current = parent_hierarchy.first_child_id(parent_id);
368
476
            while let Some(sibling_id) = current {
369
476
                if sibling_id == node_id {
370
374
                    break;
371
102
                }
372
102
                let sibling = &node_data[sibling_id.index()];
373
102
                if core::mem::discriminant(sibling.get_node_type())
374
102
                    == core::mem::discriminant(node.get_node_type())
375
102
                {
376
102
                    sibling_index += 1;
377
102
                }
378
102
                current = hierarchy[sibling_id.index()].next_sibling_id();
379
            }
380

            
381
374
            sibling_index.hash(&mut hasher);
382

            
383
374
            let parent_key = calculate_reconciliation_key(node_data, hierarchy, parent_id);
384
374
            parent_key.hash(&mut hasher);
385
493
        }
386
9265
    }
387

            
388
10132
    hasher.finish()
389
44693
}
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).
396
1089
pub fn precompute_reconciliation_keys(
397
1089
    node_data: &[NodeData],
398
1089
    hierarchy: &[NodeHierarchyItem],
399
1089
) -> Vec<u64> {
400
1089
    (0..node_data.len())
401
22152
        .map(|idx| calculate_reconciliation_key(node_data, hierarchy, NodeId::new(idx)))
402
1089
        .collect()
403
1089
}
404

            
405
/// Represents a mapping between a node in the old DOM and the new DOM.
406
#[derive(Debug, Clone, Copy)]
407
pub 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)]
416
pub 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

            
423
impl Default for DiffResult {
424
1089
    fn default() -> Self {
425
1089
        Self {
426
1089
            events: Vec::new(),
427
1089
            node_moves: Vec::new(),
428
1089
        }
429
1089
    }
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
457
1089
pub fn reconcile_dom(
458
1089
    old_node_data: &[NodeData],
459
1089
    new_node_data: &[NodeData],
460
1089
    old_hierarchy: &[NodeHierarchyItem],
461
1089
    new_hierarchy: &[NodeHierarchyItem],
462
1089
    old_layout: &OrderedMap<NodeId, LogicalRect>,
463
1089
    new_layout: &OrderedMap<NodeId, LogicalRect>,
464
1089
    dom_id: DomId,
465
1089
    timestamp: Instant,
466
1089
) -> DiffResult {
467
1089
    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
1089
    let old_rec_keys = precompute_reconciliation_keys(old_node_data, old_hierarchy);
481

            
482
1089
    let mut old_by_rec_key: OrderedMap<u64, VecDeque<NodeId>> = OrderedMap::default();
483
1089
    let mut old_hashed: OrderedMap<DomNodeHash, VecDeque<NodeId>> = OrderedMap::default();
484
1089
    let mut old_structural: OrderedMap<DomNodeHash, VecDeque<NodeId>> = OrderedMap::default();
485
1089
    let mut old_nodes_consumed = vec![false; old_node_data.len()];
486

            
487
22152
    for (idx, node) in old_node_data.iter().enumerate() {
488
22151
        let id = NodeId::new(idx);
489
22151
        old_by_rec_key.entry(old_rec_keys[idx]).or_default().push_back(id);
490
22151

            
491
22151
        let hash = node.calculate_node_data_hash();
492
22151
        old_hashed.entry(hash).or_default().push_back(id);
493
22151

            
494
22151
        let structural_hash = node.calculate_structural_hash();
495
22151
        old_structural.entry(structural_hash).or_default().push_back(id);
496
22151
    }
497

            
498
    // --- STEP 2: ITERATE NEW DOM AND CLAIM MATCHES ---
499

            
500
    // Helper: pop the first non-consumed NodeId from a queue.
501
20145
    fn pop_first_unconsumed(
502
20145
        queue: &mut VecDeque<NodeId>,
503
20145
        consumed: &[bool],
504
20145
    ) -> Option<NodeId> {
505
20196
        while let Some(&old_id) = queue.front() {
506
20145
            if consumed[old_id.index()] {
507
51
                queue.pop_front();
508
51
            } else {
509
20094
                queue.pop_front();
510
20094
                return Some(old_id);
511
            }
512
        }
513
51
        None
514
20145
    }
515

            
516
22169
    for (new_idx, new_node) in new_node_data.iter().enumerate() {
517
22168
        let new_id = NodeId::new(new_idx);
518
22168
        let mut matched_old_id = None;
519
22168
        let mut matched_by_rec_key = false;
520
22168
        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
22168
        let new_rec_key =
524
22168
            calculate_reconciliation_key(new_node_data, new_hierarchy, new_id);
525
22168
        if let Some(queue) = old_by_rec_key.get_mut(&new_rec_key) {
526
20111
            if let Some(old_id) = pop_first_unconsumed(queue, &old_nodes_consumed) {
527
20094
                matched_old_id = Some(old_id);
528
20094
                matched_by_rec_key = true;
529
20094
            }
530
2057
        }
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
22168
        if !has_explicit_key && matched_old_id.is_none() {
537
            // Tier 2: Content hash (exact match — catches pure reorders)
538
2057
            let hash = new_node.calculate_node_data_hash();
539
2057
            if let Some(queue) = old_hashed.get_mut(&hash) {
540
17
                if let Some(old_id) = pop_first_unconsumed(queue, &old_nodes_consumed) {
541
                    matched_old_id = Some(old_id);
542
17
                }
543
2040
            }
544

            
545
            // Tier 3: Structural hash (text-node fallback — ignores text content)
546
2057
            if matched_old_id.is_none() {
547
2057
                let structural_hash = new_node.calculate_structural_hash();
548
2057
                if let Some(queue) = old_structural.get_mut(&structural_hash) {
549
17
                    if let Some(old_id) = pop_first_unconsumed(queue, &old_nodes_consumed) {
550
                        matched_old_id = Some(old_id);
551
17
                    }
552
2040
                }
553
            }
554
20111
        }
555

            
556
        // --- STEP 3: PROCESS MATCH OR MOUNT ---
557

            
558
22168
        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
20094
            old_nodes_consumed[old_id.index()] = true;
562
20094
            result.node_moves.push(NodeMove {
563
20094
                old_node_id: old_id,
564
20094
                new_node_id: new_id,
565
20094
            });
566

            
567
            // Check for Resize
568
20094
            let old_rect = old_layout.get(&old_id).copied().unwrap_or(LogicalRect::zero());
569
20094
            let new_rect = new_layout.get(&new_id).copied().unwrap_or(LogicalRect::zero());
570

            
571
20094
            if old_rect.size != new_rect.size {
572
                // Fire Resize Event
573
17
                if has_resize_callback(new_node) {
574
17
                    result.events.push(create_lifecycle_event(
575
17
                        EventType::Resize,
576
17
                        new_id,
577
17
                        dom_id,
578
17
                        &timestamp,
579
17
                        LifecycleEventData {
580
17
                            reason: LifecycleReason::Resize,
581
17
                            previous_bounds: Some(old_rect),
582
17
                            current_bounds: new_rect,
583
17
                        },
584
17
                    ));
585
17
                }
586
20077
            }
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
20094
            if matched_by_rec_key {
594
20094
                let old_hash = old_node_data[old_id.index()].calculate_node_data_hash();
595
20094
                let new_hash = new_node.calculate_node_data_hash();
596

            
597
20094
                if old_hash != new_hash && has_update_callback(new_node) {
598
17
                    result.events.push(create_lifecycle_event(
599
17
                        EventType::Update,
600
17
                        new_id,
601
17
                        dom_id,
602
17
                        &timestamp,
603
17
                        LifecycleEventData {
604
17
                            reason: LifecycleReason::Update,
605
17
                            previous_bounds: Some(old_rect),
606
17
                            current_bounds: new_rect,
607
17
                        },
608
17
                    ));
609
20077
                }
610
            }
611
        } else {
612
            // NO MATCH FOUND -> MOUNT (New Node)
613
2074
            if has_mount_callback(new_node) {
614
34
                let bounds = new_layout.get(&new_id).copied().unwrap_or(LogicalRect::zero());
615
34
                result.events.push(create_lifecycle_event(
616
34
                    EventType::Mount,
617
34
                    new_id,
618
34
                    dom_id,
619
34
                    &timestamp,
620
34
                    LifecycleEventData {
621
34
                        reason: LifecycleReason::InitialMount,
622
34
                        previous_bounds: None,
623
34
                        current_bounds: bounds,
624
34
                    },
625
34
                ));
626
2040
            }
627
        }
628
    }
629

            
630
    // --- STEP 4: CLEANUP (UNMOUNTS) ---
631
    // Any old node that wasn't claimed is effectively destroyed.
632

            
633
22152
    for (old_idx, consumed) in old_nodes_consumed.iter().enumerate() {
634
22151
        if !consumed {
635
2057
            let old_id = NodeId::new(old_idx);
636
2057
            let old_node = &old_node_data[old_idx];
637

            
638
2057
            if has_unmount_callback(old_node) {
639
34
                let bounds = old_layout.get(&old_id).copied().unwrap_or(LogicalRect::zero());
640
34
                result.events.push(create_lifecycle_event(
641
34
                    EventType::Unmount,
642
34
                    old_id,
643
34
                    dom_id,
644
34
                    &timestamp,
645
34
                    LifecycleEventData {
646
34
                        reason: LifecycleReason::Unmount,
647
34
                        previous_bounds: Some(bounds),
648
34
                        current_bounds: LogicalRect::zero(),
649
34
                    },
650
34
                ));
651
2023
            }
652
20094
        }
653
    }
654

            
655
1089
    result
656
1089
}
657

            
658
/// Creates a lifecycle event with all necessary fields.
659
102
fn create_lifecycle_event(
660
102
    event_type: EventType,
661
102
    node_id: NodeId,
662
102
    dom_id: DomId,
663
102
    timestamp: &Instant,
664
102
    data: LifecycleEventData,
665
102
) -> SyntheticEvent {
666
102
    let dom_node_id = DomNodeId {
667
102
        dom: dom_id,
668
102
        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
669
102
    };
670
102
    SyntheticEvent {
671
102
        event_type,
672
102
        source: EventSource::Lifecycle,
673
102
        phase: EventPhase::Target,
674
102
        target: dom_node_id,
675
102
        current_target: dom_node_id,
676
102
        timestamp: timestamp.clone(),
677
102
        data: EventData::Lifecycle(data),
678
102
        stopped: false,
679
102
        stopped_immediate: false,
680
102
        prevented_default: false,
681
102
    }
682
102
}
683

            
684
/// Check if the node has an AfterMount callback registered.
685
2074
fn has_mount_callback(node: &NodeData) -> bool {
686
2074
    node.get_callbacks().iter().any(|cb| {
687
        matches!(
688
34
            cb.event,
689
            EventFilter::Component(ComponentEventFilter::AfterMount)
690
        )
691
34
    })
692
2074
}
693

            
694
/// Check if the node has a BeforeUnmount callback registered.
695
2057
fn has_unmount_callback(node: &NodeData) -> bool {
696
2057
    node.get_callbacks().iter().any(|cb| {
697
        matches!(
698
34
            cb.event,
699
            EventFilter::Component(ComponentEventFilter::BeforeUnmount)
700
        )
701
34
    })
702
2057
}
703

            
704
/// Check if the node has a NodeResized callback registered.
705
17
fn has_resize_callback(node: &NodeData) -> bool {
706
17
    node.get_callbacks().iter().any(|cb| {
707
        matches!(
708
17
            cb.event,
709
            EventFilter::Component(ComponentEventFilter::NodeResized)
710
        )
711
17
    })
712
17
}
713

            
714
/// Check if the node has any lifecycle callback that would respond to updates.
715
289
fn has_update_callback(node: &NodeData) -> bool {
716
289
    node.get_callbacks().iter().any(|cb| {
717
        matches!(
718
17
            cb.event,
719
            EventFilter::Component(ComponentEventFilter::Updated)
720
        )
721
17
    })
722
289
}
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
/// ```
744
120
pub fn create_migration_map(node_moves: &[NodeMove]) -> OrderedMap<NodeId, NodeId> {
745
120
    let mut map = OrderedMap::default();
746
307
    for m in node_moves {
747
187
        map.insert(m.old_node_id, m.new_node_id);
748
187
    }
749
120
    map
750
120
}
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
/// ```
774
221
pub fn transfer_states(
775
221
    old_node_data: &mut [NodeData],
776
221
    new_node_data: &mut [NodeData],
777
221
    node_moves: &[NodeMove],
778
221
) {
779
    use crate::refany::OptionRefAny;
780

            
781
459
    for movement in node_moves {
782
238
        let old_idx = movement.old_node_id.index();
783
238
        let new_idx = movement.new_node_id.index();
784

            
785
        // Bounds check
786
238
        if old_idx >= old_node_data.len() || new_idx >= new_node_data.len() {
787
17
            continue;
788
221
        }
789

            
790
        // 1. Check if the NEW node has requested a merge callback
791
221
        let merge_callback = match new_node_data[new_idx].get_merge_callback() {
792
153
            Some(cb) => cb,
793
68
            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
153
        let old_dataset = old_node_data[old_idx].take_dataset();
799
153
        let new_dataset = new_node_data[new_idx].take_dataset();
800

            
801
153
        match (new_dataset, old_dataset) {
802
119
            (Some(new_data), Some(old_data)) => {
803
119
                // 3. EXECUTE THE MERGE CALLBACK
804
119
                // The callback receives both datasets and returns the merged result
805
119
                let merged = (merge_callback.cb)(new_data, old_data);
806
119
                
807
119
                // 4. Store the merged result back in the new node
808
119
                new_node_data[new_idx].set_dataset(OptionRefAny::Some(merged));
809
119
            }
810
34
            (new_ds, old_ds) => {
811
                // One or both datasets missing - restore what we had
812
34
                if let Some(ds) = new_ds {
813
17
                    new_node_data[new_idx].set_dataset(OptionRefAny::Some(ds));
814
17
                }
815
34
                if let Some(ds) = old_ds {
816
17
                    old_node_data[old_idx].set_dataset(OptionRefAny::Some(ds));
817
17
                }
818
            }
819
        }
820
    }
821
221
}
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
841
pub fn calculate_contenteditable_key(
842
    node_data: &[NodeData],
843
    hierarchy: &[crate::styled_dom::NodeHierarchyItem],
844
    node_id: NodeId,
845
) -> u64 {
846
    use core::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 = crate::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 = crate::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
/// ```
937
391
pub fn reconcile_cursor_position(
938
391
    old_text: &str,
939
391
    new_text: &str,
940
391
    old_cursor_byte: usize,
941
391
) -> usize {
942
    // If texts are equal, cursor is unchanged
943
391
    if old_text == new_text {
944
68
        return old_cursor_byte;
945
323
    }
946
    
947
    // Empty old text - place cursor at end of new text
948
323
    if old_text.is_empty() {
949
34
        return new_text.len();
950
289
    }
951
    
952
    // Empty new text - place cursor at 0
953
289
    if new_text.is_empty() {
954
34
        return 0;
955
255
    }
956
    
957
    // Find common prefix (how many bytes from the start are identical)
958
255
    let common_prefix_bytes = old_text
959
255
        .bytes()
960
255
        .zip(new_text.bytes())
961
170782
        .take_while(|(a, b)| a == b)
962
255
        .count();
963
    
964
    // If cursor was in the unchanged prefix, it stays at the same byte offset
965
255
    if old_cursor_byte <= common_prefix_bytes {
966
136
        return old_cursor_byte.min(new_text.len());
967
119
    }
968
    
969
    // Find common suffix (how many bytes from the end are identical)
970
119
    let common_suffix_bytes = old_text
971
119
        .bytes()
972
119
        .rev()
973
119
        .zip(new_text.bytes().rev())
974
527
        .take_while(|(a, b)| a == b)
975
119
        .count();
976
    
977
    // Calculate where the suffix starts in old and new text
978
119
    let old_suffix_start = old_text.len().saturating_sub(common_suffix_bytes);
979
119
    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
119
    if old_cursor_byte >= old_suffix_start {
983
102
        let offset_from_end = old_text.len() - old_cursor_byte;
984
102
        return new_text.len().saturating_sub(offset_from_end);
985
17
    }
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
17
    new_suffix_start
990
391
}
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`.
995
255
pub fn get_node_text_content(node: &NodeData) -> Option<&str> {
996
255
    if let crate::dom::NodeType::Text(ref text) = node.get_node_type() {
997
238
        Some(text.as_str())
998
    } else {
999
17
        None
    }
255
}
// ============================================================================
// ChangeAccumulator — unifies all change input paths
// ============================================================================
/// Text change info for cursor/selection reconciliation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextChange {
    /// The text content before the change.
    pub old_text: String,
    /// The text content after the change.
    pub new_text: String,
}
/// Per-node change report combining multiple information sources.
#[derive(Debug, Clone, Default)]
pub struct NodeChangeReport {
    /// Bitflags from DOM-level field comparison.
    pub change_set: NodeChangeSet,
    /// Highest RelayoutScope from any CSS property that changed on this node.
    /// This is more granular than NodeChangeSet's binary LAYOUT/PAINT split.
    ///
    /// - `None` → repaint only (color, opacity, transform)
    /// - `IfcOnly` → reshape text in the containing IFC
    /// - `SizingOnly` → recompute this node's intrinsic size
    /// - `Full` → full subtree relayout (display, position, float, etc.)
    pub relayout_scope: RelayoutScope,
    /// Individual CSS properties that changed (for fine-grained cache invalidation).
    /// Empty if the change was structural (text content, node type, etc.)
    pub changed_css_properties: Vec<CssPropertyType>,
    /// If text content changed, the old and new text for cursor reconciliation.
    pub text_change: Option<TextChange>,
}
impl NodeChangeReport {
    /// Returns the DirtyFlag level needed for this change report.
    /// Maps RelayoutScope + NodeChangeSet → a simple tri-state.
153
    pub fn needs_layout(&self) -> bool {
153
        self.change_set.needs_layout() || self.relayout_scope > RelayoutScope::None
153
    }
68
    pub fn needs_paint(&self) -> bool {
68
        self.change_set.needs_paint()
68
    }
51
    pub fn is_visually_unchanged(&self) -> bool {
51
        self.change_set.is_visually_unchanged() && self.relayout_scope == RelayoutScope::None
51
    }
}
/// Unified change report that merges information from all three change paths:
///
/// 1. **DOM reconciliation** (`compute_node_changes` after `reconcile_dom`)
/// 2. **CSS restyle** (`restyle_on_state_change` for hover/focus/active)
/// 3. **Runtime edits** (`words_changed`, `css_properties_changed`, `images_changed`)
///
/// This is the single source of truth for "what work needs to happen this frame".
#[derive(Debug, Clone, Default)]
pub struct ChangeAccumulator {
    /// Per-node change info. Key is the new-DOM NodeId.
    pub per_node: BTreeMap<NodeId, NodeChangeReport>,
    /// Maximum RelayoutScope across all changed nodes.
    /// Quick check: if this is `None`, we can skip layout entirely.
    pub max_scope: RelayoutScope,
    /// Nodes that are newly mounted (no old counterpart).
    /// These always need full layout.
    pub mounted_nodes: Vec<NodeId>,
    /// Nodes that were unmounted (no new counterpart).
    /// Used for cleanup (remove from scroll/focus/cursor managers).
    pub unmounted_nodes: Vec<NodeId>,
}
impl ChangeAccumulator {
44
    pub fn new() -> Self {
44
        Self::default()
44
    }
    /// Returns true if no changes were detected at all.
5
    pub fn is_empty(&self) -> bool {
5
        self.per_node.is_empty() && self.mounted_nodes.is_empty() && self.unmounted_nodes.is_empty()
5
    }
    /// Returns true if layout work is needed (any node has scope > None).
306
    pub fn needs_layout(&self) -> bool {
306
        self.max_scope > RelayoutScope::None
204
            || !self.mounted_nodes.is_empty()
102
            || self.per_node.values().any(|r| r.needs_layout())
306
    }
    /// Returns true if only paint work is needed (no layout).
68
    pub fn needs_paint_only(&self) -> bool {
68
        !self.needs_layout() && self.per_node.values().any(|r| r.needs_paint())
68
    }
    /// Returns true if only non-visual changes occurred (callbacks, dataset, a11y).
119
    pub fn is_visually_unchanged(&self) -> bool {
119
        self.mounted_nodes.is_empty()
102
            && self.unmounted_nodes.is_empty()
85
            && self.max_scope == RelayoutScope::None
68
            && self.per_node.values().all(|r| r.is_visually_unchanged())
119
    }
    /// Add a node change from DOM reconciliation (Path A).
136
    pub fn add_dom_change(
136
        &mut self,
136
        new_node_id: NodeId,
136
        change_set: NodeChangeSet,
136
        relayout_scope: RelayoutScope,
136
        text_change: Option<TextChange>,
136
        changed_css_properties: Vec<CssPropertyType>,
136
    ) {
136
        if relayout_scope > self.max_scope {
102
            self.max_scope = relayout_scope;
102
        }
136
        let report = self.per_node.entry(new_node_id).or_default();
136
        report.change_set |= change_set;
136
        if relayout_scope > report.relayout_scope {
119
            report.relayout_scope = relayout_scope;
119
        }
136
        if text_change.is_some() {
119
            report.text_change = text_change;
119
        }
136
        report.changed_css_properties.extend(changed_css_properties);
136
    }
    /// Add a text change (from runtime edit or DOM reconciliation).
170
    pub fn add_text_change(
170
        &mut self,
170
        node_id: NodeId,
170
        old_text: String,
170
        new_text: String,
170
    ) {
170
        let scope = RelayoutScope::IfcOnly;
170
        if scope > self.max_scope {
170
            self.max_scope = scope;
170
        }
170
        let report = self.per_node.entry(node_id).or_default();
170
        report.change_set.insert(NodeChangeSet::TEXT_CONTENT);
170
        if scope > report.relayout_scope {
170
            report.relayout_scope = scope;
170
        }
170
        report.text_change = Some(TextChange { old_text, new_text });
170
    }
    /// Add a CSS property change (from runtime edit or restyle).
374
    pub fn add_css_change(
374
        &mut self,
374
        node_id: NodeId,
374
        prop_type: CssPropertyType,
374
        scope: RelayoutScope,
374
    ) {
374
        if scope > self.max_scope {
221
            self.max_scope = scope;
221
        }
374
        let report = self.per_node.entry(node_id).or_default();
374
        if scope > RelayoutScope::None {
238
            report.change_set.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
238
        } else {
136
            report.change_set.insert(NodeChangeSet::INLINE_STYLE_PAINT);
136
        }
374
        if scope > report.relayout_scope {
221
            report.relayout_scope = scope;
221
        }
374
        report.changed_css_properties.push(prop_type);
374
    }
    /// Add an image change (from runtime edit or DOM reconciliation).
51
    pub fn add_image_change(
51
        &mut self,
51
        node_id: NodeId,
51
        scope: RelayoutScope,
51
    ) {
51
        if scope > self.max_scope {
34
            self.max_scope = scope;
34
        }
51
        let report = self.per_node.entry(node_id).or_default();
51
        report.change_set.insert(NodeChangeSet::IMAGE_CHANGED);
51
        if scope > report.relayout_scope {
51
            report.relayout_scope = scope;
51
        }
51
    }
    /// Add a mounted (new) node.
204
    pub fn add_mount(&mut self, node_id: NodeId) {
204
        self.mounted_nodes.push(node_id);
204
    }
    /// Add an unmounted (removed) node.
170
    pub fn add_unmount(&mut self, node_id: NodeId) {
170
        self.unmounted_nodes.push(node_id);
170
    }
    /// Merge a `RestyleResult` (from `restyle_on_state_change()`) into this accumulator.
    ///
    /// This is the bridge between Path B (restyle) and the unified change pipeline.
    /// Each `ChangedCssProperty` is classified via `relayout_scope()` to determine
    /// whether it affects layout or only paint.
    pub fn merge_restyle_result(&mut self, restyle: &crate::styled_dom::RestyleResult) {
        for (node_id, changed_props) in &restyle.changed_nodes {
            for changed in changed_props {
                let prop_type = changed.current_prop.get_type();
                let scope = prop_type.relayout_scope(true); // conservative
                self.add_css_change(*node_id, prop_type, scope);
            }
        }
    }
    /// Populate this accumulator from an `ExtendedDiffResult` + the old/new DOM data.
    ///
    /// This converts per-node `NodeChangeSet` flags into full `NodeChangeReport`s
    /// with `RelayoutScope` classification.
204
    pub fn merge_extended_diff(
204
        &mut self,
204
        extended: &ExtendedDiffResult,
204
        old_node_data: &[NodeData],
204
        new_node_data: &[NodeData],
204
    ) {
442
        for &(old_id, new_id, ref change_set) in &extended.node_changes {
238
            if change_set.is_empty() {
136
                continue;
102
            }
            // Determine RelayoutScope from the change flags
102
            let scope = self.classify_change_scope(change_set, new_node_data, new_id);
            // Extract text change info if TEXT_CONTENT flag is set
102
            let text_change = if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
102
                let old_text = get_node_text_content(&old_node_data[old_id.index()])
102
                    .unwrap_or("")
102
                    .to_string();
102
                let new_text = get_node_text_content(&new_node_data[new_id.index()])
102
                    .unwrap_or("")
102
                    .to_string();
102
                Some(TextChange { old_text, new_text })
            } else {
                None
            };
102
            self.add_dom_change(new_id, *change_set, scope, text_change, Vec::new());
        }
        // Track mounts: new nodes that didn't match anything in old
204
        let matched_new: alloc::collections::BTreeSet<usize> = extended
204
            .diff
204
            .node_moves
204
            .iter()
238
            .map(|m| m.new_node_id.index())
204
            .collect();
357
        for idx in 0..new_node_data.len() {
357
            if !matched_new.contains(&idx) {
119
                self.add_mount(NodeId::new(idx));
238
            }
        }
        // Track unmounts: old nodes that didn't match anything in new
204
        let matched_old: alloc::collections::BTreeSet<usize> = extended
204
            .diff
204
            .node_moves
204
            .iter()
238
            .map(|m| m.old_node_id.index())
204
            .collect();
357
        for idx in 0..old_node_data.len() {
357
            if !matched_old.contains(&idx) {
119
                self.add_unmount(NodeId::new(idx));
238
            }
        }
204
    }
    /// Classify a NodeChangeSet into the appropriate RelayoutScope.
102
    fn classify_change_scope(
102
        &self,
102
        change_set: &NodeChangeSet,
102
        new_node_data: &[NodeData],
102
        new_node_id: NodeId,
102
    ) -> RelayoutScope {
        // NODE_TYPE_CHANGED or CHILDREN_CHANGED → Full
102
        if change_set.contains(NodeChangeSet::NODE_TYPE_CHANGED)
102
            || change_set.contains(NodeChangeSet::CHILDREN_CHANGED)
        {
            return RelayoutScope::Full;
102
        }
        // IDS_AND_CLASSES → Full (conservative: class change may add layout-affecting CSS)
102
        if change_set.contains(NodeChangeSet::IDS_AND_CLASSES) {
            return RelayoutScope::Full;
102
        }
        // INLINE_STYLE_LAYOUT → could be IfcOnly, SizingOnly, or Full
        // We need to check individual properties for the exact scope.
        // For now, we use SizingOnly as a conservative default since
        // the individual property scopes were already checked in compute_node_changes.
102
        if change_set.contains(NodeChangeSet::INLINE_STYLE_LAYOUT) {
            // Walk the inline CSS properties to find the max scope
            let new_node = &new_node_data[new_node_id.index()];
            let mut max_scope = RelayoutScope::None;
            for (prop, _conds) in new_node.style.iter_inline_properties() {
                let scope = prop.get_type().relayout_scope(true);
                if scope > max_scope {
                    max_scope = scope;
                }
            }
            return if max_scope == RelayoutScope::None {
                RelayoutScope::SizingOnly // conservative fallback
            } else {
                max_scope
            };
102
        }
        // TEXT_CONTENT → IfcOnly (reshape text, may cascade)
102
        if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
102
            return RelayoutScope::IfcOnly;
        }
        // IMAGE_CHANGED → SizingOnly (intrinsic size may change)
        if change_set.contains(NodeChangeSet::IMAGE_CHANGED) {
            return RelayoutScope::SizingOnly;
        }
        // CONTENTEDITABLE → SizingOnly
        if change_set.contains(NodeChangeSet::CONTENTEDITABLE) {
            return RelayoutScope::SizingOnly;
        }
        // Paint-only or no-visual changes
        if change_set.intersects(NodeChangeSet::AFFECTS_PAINT) {
            return RelayoutScope::None;
        }
        RelayoutScope::None
102
    }
}
/// Perform a full reconciliation with change detection.
///
/// This combines `reconcile_dom()` + `compute_node_changes()` into a single
/// pass that produces an `ExtendedDiffResult` with per-node change flags.
///
/// The `ChangeAccumulator` can then be populated from the result via
/// `accumulator.merge_extended_diff()`.
340
pub fn reconcile_dom_with_changes(
340
    old_node_data: &[NodeData],
340
    new_node_data: &[NodeData],
340
    old_hierarchy: &[NodeHierarchyItem],
340
    new_hierarchy: &[NodeHierarchyItem],
340
    old_styled_nodes: Option<&[StyledNodeState]>,
340
    new_styled_nodes: Option<&[StyledNodeState]>,
340
    old_layout: &OrderedMap<NodeId, LogicalRect>,
340
    new_layout: &OrderedMap<NodeId, LogicalRect>,
340
    dom_id: DomId,
340
    timestamp: Instant,
340
) -> ExtendedDiffResult {
    // Step 1: Run standard reconciliation
340
    let diff = reconcile_dom(
340
        old_node_data,
340
        new_node_data,
340
        old_hierarchy,
340
        new_hierarchy,
340
        old_layout,
340
        new_layout,
340
        dom_id,
340
        timestamp,
    );
    // Step 2: For each matched pair, compute what changed
340
    let mut node_changes = Vec::new();
731
    for node_move in &diff.node_moves {
391
        let old_nd = &old_node_data[node_move.old_node_id.index()];
391
        let new_nd = &new_node_data[node_move.new_node_id.index()];
391
        let old_state = old_styled_nodes.and_then(|s| s.get(node_move.old_node_id.index()));
391
        let new_state = new_styled_nodes.and_then(|s| s.get(node_move.new_node_id.index()));
391
        let changes = compute_node_changes(old_nd, new_nd, old_state, new_state);
391
        node_changes.push((node_move.old_node_id, node_move.new_node_id, changes));
    }
340
    ExtendedDiffResult { diff, node_changes }
340
}
// ============================================================================
// NodeDataFingerprint — multi-field hash for fast change detection
// ============================================================================
/// Per-node hash broken into independent fields for fast change detection.
///
/// Instead of a single u64 hash (which loses all granularity), this stores
/// separate hashes per field category. Comparing two fingerprints is O(1)
/// (6 integer comparisons) and immediately tells us WHICH category changed,
/// avoiding the more expensive `compute_node_changes()` for unchanged nodes.
///
/// Two-tier strategy:
/// - **Tier 1** (this struct): O(1) per node, identifies which categories changed.
/// - **Tier 2** (`compute_node_changes`): O(n) per changed field, does field-by-field
///   comparison only for nodes that Tier 1 identified as changed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NodeDataFingerprint {
    /// Hash of node_type (Text content, Image ref, Div, etc.)
    pub content_hash: u64,
    /// Hash of styled_node_state (hover, focus, active bits)
    pub state_hash: u64,
    /// Hash of inline CSS properties
    pub inline_css_hash: u64,
    /// Hash of ids_and_classes
    pub ids_classes_hash: u64,
    /// Hash of callbacks (event types + function pointers)
    pub callbacks_hash: u64,
    /// Hash of other attributes (contenteditable, tab_index, dataset)
    pub attrs_hash: u64,
}
impl Default for NodeDataFingerprint {
176
    fn default() -> Self {
176
        Self {
176
            content_hash: 0,
176
            state_hash: 0,
176
            inline_css_hash: 0,
176
            ids_classes_hash: 0,
176
            callbacks_hash: 0,
176
            attrs_hash: 0,
176
        }
176
    }
}
impl NodeDataFingerprint {
    /// Compute a fingerprint from a node's data and styled state.
50378
    pub fn compute(node: &NodeData, styled_state: Option<&StyledNodeState>) -> Self {
        use core::hash::Hasher;
        use core::hash::Hash;
        // Content hash
50378
        let content_hash = {
50378
            let mut h = crate::hash::DefaultHasher::new();
50378
            node.get_node_type().hash(&mut h);
50378
            h.finish()
        };
        // State hash
50378
        let state_hash = {
50378
            let mut h = crate::hash::DefaultHasher::new();
50378
            if let Some(state) = styled_state {
49545
                state.hash(&mut h);
50225
            }
50378
            h.finish()
        };
        // Inline CSS hash — full CssProperty value (matches the legacy
        // CssPropertyWithConditions::hash that hashed both property and the
        // condition vec length).
50378
        let inline_css_hash = {
50378
            let mut h = crate::hash::DefaultHasher::new();
50378
            for (prop, conds) in node.style.iter_inline_properties() {
68
                prop.hash(&mut h);
68
                conds.as_slice().len().hash(&mut h);
68
            }
50378
            h.finish()
        };
        // IDs and classes hash (now stored in attributes)
50378
        let ids_classes_hash = {
50378
            let mut h = crate::hash::DefaultHasher::new();
50378
            for attr in node.attributes().as_ref().iter() {
10241
                match attr {
118
                    crate::dom::AttributeType::Id(s) => {
118
                        crate::dom::IdOrClass::Id(s.clone()).hash(&mut h);
118
                    }
10123
                    crate::dom::AttributeType::Class(s) => {
10123
                        crate::dom::IdOrClass::Class(s.clone()).hash(&mut h);
10123
                    }
                    _ => {}
                }
            }
50378
            h.finish()
        };
        // Callbacks hash
50378
        let callbacks_hash = {
50378
            let mut h = crate::hash::DefaultHasher::new();
50378
            for cb in node.callbacks.as_ref().iter() {
                cb.event.hash(&mut h);
                cb.callback.hash(&mut h);
            }
50378
            h.finish()
        };
        // Attributes hash
50378
        let attrs_hash = {
50378
            let mut h = crate::hash::DefaultHasher::new();
50378
            node.is_contenteditable().hash(&mut h);
50378
            node.flags.hash(&mut h);
50378
            node.get_dataset().hash(&mut h);
50378
            h.finish()
        };
50378
        Self {
50378
            content_hash,
50378
            state_hash,
50378
            inline_css_hash,
50378
            ids_classes_hash,
50378
            callbacks_hash,
50378
            attrs_hash,
50378
        }
50378
    }
    /// Returns a quick NodeChangeSet by comparing two fingerprints.
    /// This is O(1) — just comparing 6 u64s.
    ///
    /// The result is *conservative*: if a field hash differs, we set the
    /// broadest applicable flag. For precise classification (e.g., which
    /// CSS properties changed and their `relayout_scope()`), the caller
    /// should fall back to `compute_node_changes()` for changed nodes.
79
    pub fn diff(&self, other: &NodeDataFingerprint) -> NodeChangeSet {
79
        let mut changes = NodeChangeSet::empty();
79
        if self.content_hash != other.content_hash {
3
            // Could be TEXT_CONTENT, IMAGE_CHANGED, or NODE_TYPE_CHANGED
3
            // We set both TEXT_CONTENT and IMAGE_CHANGED conservatively;
3
            // compute_node_changes() will refine this.
3
            changes.insert(NodeChangeSet::TEXT_CONTENT);
3
            changes.insert(NodeChangeSet::IMAGE_CHANGED);
76
        }
79
        if self.state_hash != other.state_hash {
2
            changes.insert(NodeChangeSet::STYLED_STATE);
77
        }
79
        if self.inline_css_hash != other.inline_css_hash {
1
            // Conservative: inline CSS could affect layout or paint.
1
            // compute_node_changes() checks relayout_scope() per property.
1
            changes.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
78
        }
79
        if self.ids_classes_hash != other.ids_classes_hash {
1
            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
78
        }
79
        if self.callbacks_hash != other.callbacks_hash {
            changes.insert(NodeChangeSet::CALLBACKS);
79
        }
79
        if self.attrs_hash != other.attrs_hash {
1
            changes.insert(NodeChangeSet::TAB_INDEX);
1
            changes.insert(NodeChangeSet::CONTENTEDITABLE);
78
        }
79
        changes
79
    }
    /// Returns true if the fingerprint is identical (no changes at all).
102
    pub fn is_identical(&self, other: &NodeDataFingerprint) -> bool {
102
        self == other
102
    }
    /// Quick check: could this change affect layout?
4
    pub fn might_affect_layout(&self, other: &NodeDataFingerprint) -> bool {
4
        self.content_hash != other.content_hash
3
            || self.inline_css_hash != other.inline_css_hash
3
            || self.ids_classes_hash != other.ids_classes_hash
2
            || self.attrs_hash != other.attrs_hash
4
    }
    /// Quick check: could this change affect visuals at all?
2
    pub fn might_affect_visuals(&self, other: &NodeDataFingerprint) -> bool {
2
        self.content_hash != other.content_hash
2
            || self.state_hash != other.state_hash
1
            || self.inline_css_hash != other.inline_css_hash
1
            || self.ids_classes_hash != other.ids_classes_hash
2
    }
}