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