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

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

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

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

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

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

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

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

            
132
impl core::ops::BitOrAssign for NodeChangeSet {
133
152
    fn bitor_assign(&mut self, rhs: Self) {
134
152
        self.bits |= rhs.bits;
135
152
    }
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
988
pub fn compute_node_changes(
168
988
    old_node: &NodeData,
169
988
    new_node: &NodeData,
170
988
    old_styled_state: Option<&StyledNodeState>,
171
988
    new_styled_state: Option<&StyledNodeState>,
172
988
) -> NodeChangeSet {
173
988
    let mut changes = NodeChangeSet::empty();
174

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

            
183
    // 2. Content-specific comparison (same discriminant)
184
912
    match (old_node.get_node_type(), new_node.get_node_type()) {
185
475
        (NodeType::Text(old_text), NodeType::Text(new_text)) => {
186
475
            if old_text.as_str() != new_text.as_str() {
187
323
                changes.insert(NodeChangeSet::TEXT_CONTENT);
188
323
            }
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
437
        _ => {} // 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
912
        let old_ids_classes: Vec<_> = old_node.attributes().as_ref().iter()
210
912
            .filter(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
211
912
            .collect();
212
912
        let new_ids_classes: Vec<_> = new_node.attributes().as_ref().iter()
213
912
            .filter(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
214
912
            .collect();
215
912
        if old_ids_classes != new_ids_classes {
216
95
            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
217
817
        }
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
912
    if old_node.style != new_node.style {
225
114
        let mut has_layout = false;
226
114
        let mut has_paint = false;
227

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

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

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

            
266
114
        if has_layout {
267
76
            changes.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
268
76
        }
269
114
        if has_paint {
270
38
            changes.insert(NodeChangeSet::INLINE_STYLE_PAINT);
271
76
        }
272
798
    }
273

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

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

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

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

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

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

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

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

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

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

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

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

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

            
383
418
            let parent_key = calculate_reconciliation_key(node_data, hierarchy, parent_id);
384
418
            parent_key.hash(&mut hasher);
385
551
        }
386
10355
    }
387

            
388
11324
    hasher.finish()
389
49951
}
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
1217
pub fn precompute_reconciliation_keys(
397
1217
    node_data: &[NodeData],
398
1217
    hierarchy: &[NodeHierarchyItem],
399
1217
) -> Vec<u64> {
400
1217
    (0..node_data.len())
401
24758
        .map(|idx| calculate_reconciliation_key(node_data, hierarchy, NodeId::new(idx)))
402
1217
        .collect()
403
1217
}
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
1217
    fn default() -> Self {
425
1217
        Self {
426
1217
            events: Vec::new(),
427
1217
            node_moves: Vec::new(),
428
1217
        }
429
1217
    }
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
1217
pub fn reconcile_dom(
458
1217
    old_node_data: &[NodeData],
459
1217
    new_node_data: &[NodeData],
460
1217
    old_hierarchy: &[NodeHierarchyItem],
461
1217
    new_hierarchy: &[NodeHierarchyItem],
462
1217
    old_layout: &OrderedMap<NodeId, LogicalRect>,
463
1217
    new_layout: &OrderedMap<NodeId, LogicalRect>,
464
1217
    dom_id: DomId,
465
1217
    timestamp: Instant,
466
1217
) -> DiffResult {
467
1217
    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
1217
    let old_rec_keys = precompute_reconciliation_keys(old_node_data, old_hierarchy);
481

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
655
1217
    result
656
1217
}
657

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

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

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

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

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

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

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

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

            
801
171
        match (new_dataset, old_dataset) {
802
133
            (Some(new_data), Some(old_data)) => {
803
                // The fresh DOM's dataset allocation. A widget builds its dataset,
804
                // its VirtualView content `refany`, AND its event-callback
805
                // `refany`s from clones of ONE `RefAny` — so every one shares THIS
806
                // allocation (`RefAny::clone` shares `sharing_info`; only the
807
                // per-clone `instance_id` differs). The merge below keeps the
808
                // PERSISTENT (old) allocation (e.g. MapWidget shares its tile cache
809
                // so background fetch threads keep writing into it), so every clone
810
                // of the fresh one is now orphaned and must be re-pointed — or the
811
                // widget fragments across two caches: the VirtualView rendered an
812
                // empty clone (blank/grey tiles) while the live data sat in the
813
                // dataset, and pan/zoom mutated yet a third copy. Identity = the
814
                // shared `RefCountInner` pointer (`sharing_info.ptr`).
815
133
                let orphan_alloc = new_data.sharing_info.ptr as usize;
816

            
817
                // 3. EXECUTE THE MERGE CALLBACK
818
                // The callback receives both datasets and returns the merged result
819
133
                let merged = (merge_callback.cb)(new_data, old_data);
820

            
821
                // 4. Store the merged result back in the new node
822
133
                new_node_data[new_idx].set_dataset(OptionRefAny::Some(merged.clone()));
823

            
824
                // 5. UNIFY: re-point every refany across the NEW DOM that was a
825
                // clone of the now-discarded fresh dataset onto the merged result,
826
                // so the whole widget reads ONE cache. Covers VirtualView content
827
                // refanys + event-callback refanys + any node's dataset cloned
828
                // from the same source. (Generalises the old special-case that
829
                // only re-pointed a VirtualView ON the merge node itself — the
830
                // MapWidget puts its VirtualView in a CHILD and its pan/zoom
831
                // callbacks on the parent, which that case missed.)
832
209
                for nd in new_node_data.iter_mut() {
833
209
                    if let Some(vv) = nd.get_virtual_view_node() {
834
                        if vv.refany.sharing_info.ptr as usize == orphan_alloc {
835
                            vv.refany = merged.clone();
836
                        }
837
209
                    }
838
209
                    for cb in nd.callbacks.as_mut().iter_mut() {
839
                        if cb.refany.sharing_info.ptr as usize == orphan_alloc {
840
                            cb.refany = merged.clone();
841
                        }
842
                    }
843
209
                    let ds_is_orphan = nd
844
209
                        .get_dataset()
845
209
                        .map(|ds| ds.sharing_info.ptr as usize == orphan_alloc)
846
209
                        .unwrap_or(false);
847
209
                    if ds_is_orphan {
848
114
                        nd.set_dataset(OptionRefAny::Some(merged.clone()));
849
114
                    }
850
                }
851
            }
852
38
            (new_ds, old_ds) => {
853
                // One or both datasets missing - restore what we had
854
38
                if let Some(ds) = new_ds {
855
19
                    new_node_data[new_idx].set_dataset(OptionRefAny::Some(ds));
856
19
                }
857
38
                if let Some(ds) = old_ds {
858
19
                    old_node_data[old_idx].set_dataset(OptionRefAny::Some(ds));
859
19
                }
860
            }
861
        }
862
    }
863
247
}
864

            
865
/// Calculate a stable key for a contenteditable node using the hierarchy:
866
///
867
/// 1. **Explicit Key** - If `.with_key()` was called, use that
868
/// 2. **CSS ID** - If the node has a CSS ID (e.g., `#my-editor`), hash that
869
/// 3. **Structural Key** - Hash of `(nth-of-type, parent_key)` recursively
870
///
871
/// The structural key prevents shifting when elements are inserted before siblings.
872
/// For example, in `<div><p>A</p><p contenteditable>B</p></div>`, if we insert
873
/// a new `<p>` at the start, the contenteditable `<p>` becomes nth-child(3) but
874
/// its nth-of-type stays stable (it's still the 2nd `<p>`).
875
///
876
/// # Arguments
877
/// * `node_data` - All nodes in the DOM
878
/// * `hierarchy` - Parent-child relationships
879
/// * `node_id` - The node to calculate the key for
880
///
881
/// # Returns
882
/// A stable u64 key for the node
883
pub fn calculate_contenteditable_key(
884
    node_data: &[NodeData],
885
    hierarchy: &[crate::styled_dom::NodeHierarchyItem],
886
    node_id: NodeId,
887
) -> u64 {
888
    use core::hash::Hasher;
889
    
890
    let node = &node_data[node_id.index()];
891
    
892
    // Priority 1: Explicit key (from .with_key())
893
    if let Some(explicit_key) = node.get_key() {
894
        return explicit_key;
895
    }
896
    
897
    // Priority 2: CSS ID
898
    for attr in node.attributes().as_ref().iter() {
899
        if let Some(id) = attr.as_id() {
900
            let mut hasher = crate::hash::DefaultHasher::new(); // Different seed for ID keys
901
            hasher.write(id.as_bytes());
902
            return hasher.finish();
903
        }
904
    }
905
    
906
    // Priority 3: Structural key = (nth-of-type, classes, parent_key)
907
    let mut hasher = crate::hash::DefaultHasher::new(); // Different seed for structural keys
908
    
909
    // Get parent and calculate its key recursively
910
    let parent_key = if let Some(parent_id) = hierarchy.get(node_id.index()).and_then(|h| h.parent_id()) {
911
        calculate_contenteditable_key(node_data, hierarchy, parent_id)
912
    } else {
913
        0u64 // Root node
914
    };
915
    hasher.write(&parent_key.to_le_bytes());
916
    
917
    // Calculate nth-of-type (count siblings of same node type before this one)
918
    // We compare discriminants directly without hashing
919
    let node_discriminant = core::mem::discriminant(node.get_node_type());
920
    let nth_of_type = if let Some(parent_id) = hierarchy.get(node_id.index()).and_then(|h| h.parent_id()) {
921
        // Count siblings with same node type that come before this node
922
        let mut count = 0u32;
923
        let mut sibling_id = hierarchy.get(parent_id.index()).and_then(|h| h.first_child_id(parent_id));
924
        while let Some(sib_id) = sibling_id {
925
            if sib_id == node_id {
926
                break;
927
            }
928
            let sibling_discriminant = core::mem::discriminant(node_data[sib_id.index()].get_node_type());
929
            if sibling_discriminant == node_discriminant {
930
                count += 1;
931
            }
932
            sibling_id = hierarchy.get(sib_id.index()).and_then(|h| h.next_sibling_id());
933
        }
934
        count
935
    } else {
936
        0
937
    };
938
    
939
    hasher.write(&nth_of_type.to_le_bytes());
940
    
941
    // Hash the node type discriminant (Discriminant<T> implements Hash)
942
    node_discriminant.hash(&mut hasher);
943
    
944
    // Also hash the classes for additional stability
945
    for attr in node.attributes().as_ref().iter() {
946
        if let Some(class) = attr.as_class() {
947
            hasher.write(class.as_bytes());
948
        }
949
    }
950
    
951
    hasher.finish()
952
}
953

            
954
/// Reconcile cursor byte position when text content changes.
955
///
956
/// This function maps a cursor position from old text to new text, preserving
957
/// the cursor's logical position as much as possible:
958
///
959
/// 1. If cursor is in unchanged prefix → stays at same byte offset
960
/// 2. If cursor is in unchanged suffix → adjusts by length difference
961
/// 3. If cursor is in changed region → places at end of new content
962
///
963
/// # Arguments
964
/// * `old_text` - The previous text content
965
/// * `new_text` - The new text content
966
/// * `old_cursor_byte` - Cursor byte offset in old text
967
///
968
/// # Returns
969
/// The reconciled cursor byte offset in new text
970
///
971
/// # Example
972
/// ```rust,ignore
973
/// let old_text = "Hello";
974
/// let new_text = "Hello World";
975
/// let old_cursor = 5; // cursor at end of "Hello"
976
/// let new_cursor = reconcile_cursor_position(old_text, new_text, old_cursor);
977
/// assert_eq!(new_cursor, 5); // cursor stays at same position (prefix unchanged)
978
/// ```
979
437
pub fn reconcile_cursor_position(
980
437
    old_text: &str,
981
437
    new_text: &str,
982
437
    old_cursor_byte: usize,
983
437
) -> usize {
984
    // If texts are equal, cursor is unchanged
985
437
    if old_text == new_text {
986
76
        return old_cursor_byte;
987
361
    }
988
    
989
    // Empty old text - place cursor at end of new text
990
361
    if old_text.is_empty() {
991
38
        return new_text.len();
992
323
    }
993
    
994
    // Empty new text - place cursor at 0
995
323
    if new_text.is_empty() {
996
38
        return 0;
997
285
    }
998
    
999
    // Find common prefix (how many bytes from the start are identical)
285
    let common_prefix_bytes = old_text
285
        .bytes()
285
        .zip(new_text.bytes())
190874
        .take_while(|(a, b)| a == b)
285
        .count();
    // If cursor was in the unchanged prefix, it stays at the same byte offset
285
    if old_cursor_byte <= common_prefix_bytes {
152
        return old_cursor_byte.min(new_text.len());
133
    }
    // Find common suffix (how many bytes from the end are identical)
133
    let common_suffix_bytes = old_text
133
        .bytes()
133
        .rev()
133
        .zip(new_text.bytes().rev())
589
        .take_while(|(a, b)| a == b)
133
        .count();
    // Calculate where the suffix starts in old and new text
133
    let old_suffix_start = old_text.len().saturating_sub(common_suffix_bytes);
133
    let new_suffix_start = new_text.len().saturating_sub(common_suffix_bytes);
    // If cursor was in the unchanged suffix, adjust by length difference
133
    if old_cursor_byte >= old_suffix_start {
114
        let offset_from_end = old_text.len() - old_cursor_byte;
114
        return new_text.len().saturating_sub(offset_from_end);
19
    }
    // Cursor was in the changed region - place at end of inserted content
    // This handles insertions (cursor moves with new text) and deletions (cursor at edit point)
19
    new_suffix_start
437
}
/// Get the text content from a NodeData if it's a Text node.
///
/// Returns the text string if the node is `NodeType::Text`, otherwise `None`.
285
pub fn get_node_text_content(node: &NodeData) -> Option<&str> {
285
    if let crate::dom::NodeType::Text(ref text) = node.get_node_type() {
266
        Some(text.as_str())
    } else {
19
        None
    }
285
}
// ============================================================================
// 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.
171
    pub fn needs_layout(&self) -> bool {
171
        self.change_set.needs_layout() || self.relayout_scope > RelayoutScope::None
171
    }
76
    pub fn needs_paint(&self) -> bool {
76
        self.change_set.needs_paint()
76
    }
57
    pub fn is_visually_unchanged(&self) -> bool {
57
        self.change_set.is_visually_unchanged() && self.relayout_scope == RelayoutScope::None
57
    }
}
/// 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).
342
    pub fn needs_layout(&self) -> bool {
342
        self.max_scope > RelayoutScope::None
228
            || !self.mounted_nodes.is_empty()
114
            || self.per_node.values().any(|r| r.needs_layout())
342
    }
    /// Returns true if only paint work is needed (no layout).
76
    pub fn needs_paint_only(&self) -> bool {
76
        !self.needs_layout() && self.per_node.values().any(|r| r.needs_paint())
76
    }
    /// Returns true if only non-visual changes occurred (callbacks, dataset, a11y).
133
    pub fn is_visually_unchanged(&self) -> bool {
133
        self.mounted_nodes.is_empty()
114
            && self.unmounted_nodes.is_empty()
95
            && self.max_scope == RelayoutScope::None
76
            && self.per_node.values().all(|r| r.is_visually_unchanged())
133
    }
    /// Add a node change from DOM reconciliation (Path A).
152
    pub fn add_dom_change(
152
        &mut self,
152
        new_node_id: NodeId,
152
        change_set: NodeChangeSet,
152
        relayout_scope: RelayoutScope,
152
        text_change: Option<TextChange>,
152
        changed_css_properties: Vec<CssPropertyType>,
152
    ) {
152
        if relayout_scope > self.max_scope {
114
            self.max_scope = relayout_scope;
114
        }
152
        let report = self.per_node.entry(new_node_id).or_default();
152
        report.change_set |= change_set;
152
        if relayout_scope > report.relayout_scope {
133
            report.relayout_scope = relayout_scope;
133
        }
152
        if text_change.is_some() {
133
            report.text_change = text_change;
133
        }
152
        report.changed_css_properties.extend(changed_css_properties);
152
    }
    /// Add a text change (from runtime edit or DOM reconciliation).
190
    pub fn add_text_change(
190
        &mut self,
190
        node_id: NodeId,
190
        old_text: String,
190
        new_text: String,
190
    ) {
190
        let scope = RelayoutScope::IfcOnly;
190
        if scope > self.max_scope {
190
            self.max_scope = scope;
190
        }
190
        let report = self.per_node.entry(node_id).or_default();
190
        report.change_set.insert(NodeChangeSet::TEXT_CONTENT);
190
        if scope > report.relayout_scope {
190
            report.relayout_scope = scope;
190
        }
190
        report.text_change = Some(TextChange { old_text, new_text });
190
    }
    /// Add a CSS property change (from runtime edit or restyle).
418
    pub fn add_css_change(
418
        &mut self,
418
        node_id: NodeId,
418
        prop_type: CssPropertyType,
418
        scope: RelayoutScope,
418
    ) {
418
        if scope > self.max_scope {
247
            self.max_scope = scope;
247
        }
418
        let report = self.per_node.entry(node_id).or_default();
418
        if scope > RelayoutScope::None {
266
            report.change_set.insert(NodeChangeSet::INLINE_STYLE_LAYOUT);
266
        } else {
152
            report.change_set.insert(NodeChangeSet::INLINE_STYLE_PAINT);
152
        }
418
        if scope > report.relayout_scope {
247
            report.relayout_scope = scope;
247
        }
418
        report.changed_css_properties.push(prop_type);
418
    }
    /// Add an image change (from runtime edit or DOM reconciliation).
57
    pub fn add_image_change(
57
        &mut self,
57
        node_id: NodeId,
57
        scope: RelayoutScope,
57
    ) {
57
        if scope > self.max_scope {
38
            self.max_scope = scope;
38
        }
57
        let report = self.per_node.entry(node_id).or_default();
57
        report.change_set.insert(NodeChangeSet::IMAGE_CHANGED);
57
        if scope > report.relayout_scope {
57
            report.relayout_scope = scope;
57
        }
57
    }
    /// Add a mounted (new) node.
228
    pub fn add_mount(&mut self, node_id: NodeId) {
228
        self.mounted_nodes.push(node_id);
228
    }
    /// Add an unmounted (removed) node.
190
    pub fn add_unmount(&mut self, node_id: NodeId) {
190
        self.unmounted_nodes.push(node_id);
190
    }
    /// 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.
228
    pub fn merge_extended_diff(
228
        &mut self,
228
        extended: &ExtendedDiffResult,
228
        old_node_data: &[NodeData],
228
        new_node_data: &[NodeData],
228
    ) {
494
        for &(old_id, new_id, ref change_set) in &extended.node_changes {
266
            if change_set.is_empty() {
152
                continue;
114
            }
            // Determine RelayoutScope from the change flags
114
            let scope = self.classify_change_scope(change_set, new_node_data, new_id);
            // Extract text change info if TEXT_CONTENT flag is set
114
            let text_change = if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
114
                let old_text = get_node_text_content(&old_node_data[old_id.index()])
114
                    .unwrap_or("")
114
                    .to_string();
114
                let new_text = get_node_text_content(&new_node_data[new_id.index()])
114
                    .unwrap_or("")
114
                    .to_string();
114
                Some(TextChange { old_text, new_text })
            } else {
                None
            };
114
            self.add_dom_change(new_id, *change_set, scope, text_change, Vec::new());
        }
        // Track mounts: new nodes that didn't match anything in old
228
        let matched_new: alloc::collections::BTreeSet<usize> = extended
228
            .diff
228
            .node_moves
228
            .iter()
266
            .map(|m| m.new_node_id.index())
228
            .collect();
399
        for idx in 0..new_node_data.len() {
399
            if !matched_new.contains(&idx) {
133
                self.add_mount(NodeId::new(idx));
266
            }
        }
        // Track unmounts: old nodes that didn't match anything in new
228
        let matched_old: alloc::collections::BTreeSet<usize> = extended
228
            .diff
228
            .node_moves
228
            .iter()
266
            .map(|m| m.old_node_id.index())
228
            .collect();
399
        for idx in 0..old_node_data.len() {
399
            if !matched_old.contains(&idx) {
133
                self.add_unmount(NodeId::new(idx));
266
            }
        }
228
    }
    /// Classify a NodeChangeSet into the appropriate RelayoutScope.
114
    fn classify_change_scope(
114
        &self,
114
        change_set: &NodeChangeSet,
114
        new_node_data: &[NodeData],
114
        new_node_id: NodeId,
114
    ) -> RelayoutScope {
        // NODE_TYPE_CHANGED or CHILDREN_CHANGED → Full
114
        if change_set.contains(NodeChangeSet::NODE_TYPE_CHANGED)
114
            || change_set.contains(NodeChangeSet::CHILDREN_CHANGED)
        {
            return RelayoutScope::Full;
114
        }
        // IDS_AND_CLASSES → Full (conservative: class change may add layout-affecting CSS)
114
        if change_set.contains(NodeChangeSet::IDS_AND_CLASSES) {
            return RelayoutScope::Full;
114
        }
        // 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.
114
        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
            };
114
        }
        // TEXT_CONTENT → IfcOnly (reshape text, may cascade)
114
        if change_set.contains(NodeChangeSet::TEXT_CONTENT) {
114
            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
114
    }
}
/// 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()`.
380
pub fn reconcile_dom_with_changes(
380
    old_node_data: &[NodeData],
380
    new_node_data: &[NodeData],
380
    old_hierarchy: &[NodeHierarchyItem],
380
    new_hierarchy: &[NodeHierarchyItem],
380
    old_styled_nodes: Option<&[StyledNodeState]>,
380
    new_styled_nodes: Option<&[StyledNodeState]>,
380
    old_layout: &OrderedMap<NodeId, LogicalRect>,
380
    new_layout: &OrderedMap<NodeId, LogicalRect>,
380
    dom_id: DomId,
380
    timestamp: Instant,
380
) -> ExtendedDiffResult {
    // Step 1: Run standard reconciliation
380
    let diff = reconcile_dom(
380
        old_node_data,
380
        new_node_data,
380
        old_hierarchy,
380
        new_hierarchy,
380
        old_layout,
380
        new_layout,
380
        dom_id,
380
        timestamp,
    );
    // Step 2: For each matched pair, compute what changed
380
    let mut node_changes = Vec::new();
817
    for node_move in &diff.node_moves {
437
        let old_nd = &old_node_data[node_move.old_node_id.index()];
437
        let new_nd = &new_node_data[node_move.new_node_id.index()];
437
        let old_state = old_styled_nodes.and_then(|s| s.get(node_move.old_node_id.index()));
437
        let new_state = new_styled_nodes.and_then(|s| s.get(node_move.new_node_id.index()));
437
        let changes = compute_node_changes(old_nd, new_nd, old_state, new_state);
437
        node_changes.push((node_move.old_node_id, node_move.new_node_id, changes));
    }
380
    ExtendedDiffResult { diff, node_changes }
380
}
// ============================================================================
// 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 {
265
    fn default() -> Self {
265
        Self {
265
            content_hash: 0,
265
            state_hash: 0,
265
            inline_css_hash: 0,
265
            ids_classes_hash: 0,
265
            callbacks_hash: 0,
265
            attrs_hash: 0,
265
        }
265
    }
}
impl NodeDataFingerprint {
    /// Compute a fingerprint from a node's data and styled state.
116821
    pub fn compute(node: &NodeData, styled_state: Option<&StyledNodeState>) -> Self {
        use core::hash::Hasher;
        use core::hash::Hash;
        // Content hash
116821
        let content_hash = {
116821
            let mut h = crate::hash::DefaultHasher::new();
116821
            node.get_node_type().hash(&mut h);
116821
            h.finish()
        };
        // State hash
116821
        let state_hash = {
116821
            let mut h = crate::hash::DefaultHasher::new();
116821
            if let Some(state) = styled_state {
115890
                state.hash(&mut h);
116650
            }
116821
            h.finish()
        };
        // Inline CSS hash — full CssProperty value (matches the legacy
        // CssPropertyWithConditions::hash that hashed both property and the
        // condition vec length).
116821
        let inline_css_hash = {
116821
            let mut h = crate::hash::DefaultHasher::new();
116821
            for (prop, conds) in node.style.iter_inline_properties() {
76
                prop.hash(&mut h);
76
                conds.as_slice().len().hash(&mut h);
76
            }
116821
            h.finish()
        };
        // IDs and classes hash (now stored in attributes)
116821
        let ids_classes_hash = {
116821
            let mut h = crate::hash::DefaultHasher::new();
116821
            for attr in node.attributes().as_ref().iter() {
24001
                match attr {
140
                    crate::dom::AttributeType::Id(s) => {
140
                        crate::dom::IdOrClass::Id(s.clone()).hash(&mut h);
140
                    }
23861
                    crate::dom::AttributeType::Class(s) => {
23861
                        crate::dom::IdOrClass::Class(s.clone()).hash(&mut h);
23861
                    }
                    _ => {}
                }
            }
116821
            h.finish()
        };
        // Callbacks hash
116821
        let callbacks_hash = {
116821
            let mut h = crate::hash::DefaultHasher::new();
116821
            for cb in node.callbacks.as_ref().iter() {
14535
                cb.event.hash(&mut h);
14535
                cb.callback.hash(&mut h);
14535
            }
116821
            h.finish()
        };
        // Attributes hash
116821
        let attrs_hash = {
116821
            let mut h = crate::hash::DefaultHasher::new();
116821
            node.is_contenteditable().hash(&mut h);
116821
            node.flags.hash(&mut h);
116821
            node.get_dataset().hash(&mut h);
116821
            h.finish()
        };
116821
        Self {
116821
            content_hash,
116821
            state_hash,
116821
            inline_css_hash,
116821
            ids_classes_hash,
116821
            callbacks_hash,
116821
            attrs_hash,
116821
        }
116821
    }
    /// 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.
2297
    pub fn diff(&self, other: &NodeDataFingerprint) -> NodeChangeSet {
2297
        let mut changes = NodeChangeSet::empty();
2297
        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);
2294
        }
2297
        if self.state_hash != other.state_hash {
2
            changes.insert(NodeChangeSet::STYLED_STATE);
2295
        }
2297
        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);
2296
        }
2297
        if self.ids_classes_hash != other.ids_classes_hash {
1
            changes.insert(NodeChangeSet::IDS_AND_CLASSES);
2296
        }
2297
        if self.callbacks_hash != other.callbacks_hash {
            changes.insert(NodeChangeSet::CALLBACKS);
2297
        }
2297
        if self.attrs_hash != other.attrs_hash {
89
            changes.insert(NodeChangeSet::TAB_INDEX);
89
            changes.insert(NodeChangeSet::CONTENTEDITABLE);
2208
        }
2297
        changes
2297
    }
    /// Returns true if the fingerprint is identical (no changes at all).
114
    pub fn is_identical(&self, other: &NodeDataFingerprint) -> bool {
114
        self == other
114
    }
    /// 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
    }
}