Skip to main content

azul_layout/solver3/
cache.rs

1//! Handling Viewport Resizing and Layout Thrashing
2//!
3//! The viewport size is a fundamental input to the entire layout process.
4//! A change in viewport size must trigger a relayout.
5//!
6//! 1. The `layout_document` function takes the `viewport` as an argument. The `LayoutCache` stores
7//!    the `viewport` from the previous frame.
8//! 2. The `reconcile_and_invalidate` function detects that the viewport has changed size
9//! 3. This single change—marking the root as a layout root—forces a full top-down pass
10//!    (`calculate_layout_for_subtree` starting from the root). This correctly recalculates all
11//!    percentage-based sizes and repositions all elements according to the new viewport dimensions.
12//! 4. The intrinsic size calculation (bottom-up) can often be skipped, as it's independent of the
13//!    container size, which is a significant optimization.
14
15use std::{
16    collections::{BTreeMap, BTreeSet, HashMap},
17    hash::{DefaultHasher, Hash, Hasher},
18};
19
20/// Floating-point comparison epsilon for cache size lookups.
21/// Controls the tolerance for cache hit matching in the per-node multi-slot cache.
22const CACHE_SIZE_EPSILON: f32 = 0.1;
23
24use azul_core::{
25    diff::NodeDataFingerprint,
26    dom::{FormattingContext, NodeId, NodeType},
27    geom::{LogicalPosition, LogicalRect, LogicalSize},
28    styled_dom::{StyledDom, StyledNode},
29};
30use azul_css::{
31    css::CssPropertyValue,
32    props::{
33        layout::{
34            LayoutDisplay, LayoutHeight, LayoutOverflow,
35            LayoutPosition, LayoutWritingMode,
36        },
37        property::{CssProperty, CssPropertyType},
38        style::StyleTextAlign,
39    },
40    LayoutDebugMessage, LayoutDebugMessageType,
41};
42
43use crate::{
44    font_traits::{FontLoaderTrait, ParsedFontTrait, TextLayoutCache},
45    solver3::{
46        fc::{self, layout_formatting_context, LayoutConstraints, OverflowBehavior},
47        geometry::PositionedRectangle,
48        getters::{
49            get_css_height, get_display_property, get_overflow_x,
50            get_overflow_y, get_scrollbar_gutter_property, get_text_align, get_white_space_property, get_writing_mode,
51            MultiValue,
52        },
53        layout_tree::{
54            get_display_type, is_block_level, AnonymousBoxType, DirtyFlag, LayoutNode, LayoutNodeHot, LayoutTreeBuilder, SubtreeHash,
55        },
56        positioning::get_position_type,
57        scrollbar::ScrollbarRequirements,
58        sizing::calculate_used_size_for_node,
59        LayoutContext, LayoutError, LayoutTree, Result,
60    },
61    text3::cache::AvailableSpace as Text3AvailableSpace,
62};
63
64// ============================================================================
65// Per-Node Multi-Slot Cache (inspired by Taffy's 9+1 slot cache architecture)
66//
67// Instead of a global BTreeMap keyed by (node_index, available_size), each node
68// gets its own deterministic cache with 9 measurement slots + 1 full layout slot.
69// This eliminates O(log n) lookups, prevents slot collisions between MinContent/
70// MaxContent/Definite measurements, and cleanly separates sizing from positioning.
71//
72// Reference: https://github.com/DioxusLabs/taffy — Cache struct in src/tree/cache.rs
73// Azul improvement: cache is EXTERNAL (Vec<NodeCache> parallel to LayoutTree.nodes)
74// rather than stored on the node, keeping LayoutNode slim and avoiding &mut tree
75// for cache operations.
76// ============================================================================
77
78/// Determines whether `calculate_layout_for_subtree` should only compute
79/// the node's size (for parent's sizing pass) or perform full layout
80/// including child positioning.
81///
82/// Inspired by Taffy's `RunMode` enum. The two-mode approach enables the
83/// classic CSS two-pass layout: Pass 1 (`ComputeSize`) measures all children,
84/// Pass 2 (`PerformLayout`) positions them using the measured sizes.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ComputeMode {
87    /// Only compute the node's border-box size and baseline.
88    /// Does NOT store child positions. Used in BFC Pass 1 (sizing).
89    ComputeSize,
90    /// Compute size AND position all children.
91    /// Stores the full layout result including child positions.
92    /// Used in BFC Pass 2 (positioning) and as the final layout step.
93    PerformLayout,
94}
95
96/// Constraint classification for deterministic cache slot selection.
97///
98/// Inspired by Taffy's `AvailableSpace` enum. Each constraint type maps to a
99/// different cache slot, preventing collisions between e.g. `MinContent` and
100/// Definite measurements of the same node.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum AvailableWidthType {
103    /// A definite pixel value (or percentage resolved to pixels).
104    Definite,
105    /// Shrink-to-fit: the smallest size that doesn't cause overflow.
106    MinContent,
107    /// Use all available space: the largest size the content can use.
108    MaxContent,
109}
110
111/// Cache entry for sizing (`ComputeSize` mode) — stores NO positions.
112///
113/// This is the lightweight entry stored in the 9 measurement slots.
114/// It records what constraints were provided and what size resulted,
115/// enabling Taffy's "result matches request" optimization.
116#[derive(Copy, Debug, Clone)]
117pub struct SizingCacheEntry {
118    /// The available size that was provided as input.
119    pub available_size: LogicalSize,
120    /// The computed border-box size (output).
121    pub result_size: LogicalSize,
122    /// Baseline for inline alignment (if applicable).
123    pub baseline: Option<f32>,
124    /// First child's escaped top margin (CSS 2.2 § 8.3.1).
125    pub escaped_top_margin: Option<f32>,
126    /// Last child's escaped bottom margin (CSS 2.2 § 8.3.1).
127    pub escaped_bottom_margin: Option<f32>,
128}
129
130/// Cache entry for full layout (`PerformLayout` mode).
131///
132/// This is the single "final layout" slot. It includes child positions
133/// (relative to parent's content-box) and overflow/scrollbar info.
134#[derive(Debug, Clone)]
135pub struct LayoutCacheEntry {
136    /// The available size that was provided as input.
137    pub available_size: LogicalSize,
138    /// The computed border-box size (output).
139    pub result_size: LogicalSize,
140    /// Content overflow size (for scrolling).
141    pub content_size: LogicalSize,
142    /// Child positions relative to parent's content-box (NOT absolute).
143    pub child_positions: Vec<(usize, LogicalPosition)>,
144    /// First child's escaped top margin.
145    pub escaped_top_margin: Option<f32>,
146    /// Last child's escaped bottom margin.
147    pub escaped_bottom_margin: Option<f32>,
148    /// Scrollbar requirements for this node.
149    pub scrollbar_info: ScrollbarRequirements,
150}
151
152/// Per-node cache entry with 9 measurement slots + 1 full layout slot.
153///
154/// Inspired by Taffy's `Cache` struct (9+1 slots per node). The deterministic
155/// slot index is computed from the constraint combination, so entries never
156/// clobber each other (unlike the old global `BTreeMap` where fixed-point
157/// collisions were possible).
158///
159/// NOT stored on `LayoutNode` — lives in the external `LayoutCacheMap`.
160#[derive(Debug, Clone)]
161pub struct NodeCache {
162    /// 9 measurement slots (Taffy's deterministic scheme):
163    /// - Slot 0: both dimensions known
164    /// - Slots 1-2: only width known (MaxContent/Definite vs `MinContent`)
165    /// - Slots 3-4: only height known (MaxContent/Definite vs `MinContent`)
166    /// - Slots 5-8: neither known (2×2 combos of width/height constraint types)
167    pub measure_entries: [Option<SizingCacheEntry>; 9],
168
169    /// 1 full layout slot (with child positions, overflow, baseline).
170    /// Only populated after `PerformLayout`, not after `ComputeSize`.
171    pub layout_entry: Option<LayoutCacheEntry>,
172
173    /// Fast check for dirty propagation (Taffy optimization).
174    /// When true, all slots are empty — ancestors are also dirty.
175    pub is_empty: bool,
176}
177
178impl Default for NodeCache {
179    fn default() -> Self {
180        Self {
181            measure_entries: [None, None, None, None, None, None, None, None, None],
182            layout_entry: None,
183            is_empty: true, // fresh cache is empty/dirty
184        }
185    }
186}
187
188impl NodeCache {
189    /// Clear all cache entries, marking this node as dirty.
190    pub fn clear(&mut self) {
191        self.measure_entries = [None, None, None, None, None, None, None, None, None];
192        self.layout_entry = None;
193        self.is_empty = true;
194    }
195
196    /// Compute the deterministic slot index from constraint dimensions.
197    ///
198    /// This is Taffy's slot selection scheme: given whether width/height are
199    /// "known" (definite constraint provided by parent) and what type of
200    /// constraint applies to the unknown dimension(s), we get a unique slot 0–8.
201    ///
202    /// TODO(superplan): currently unused — the layout cache only ever touches
203    /// slot 0 (see the `get_size(0, ..)` / `store_size(0, ..)` call sites). This
204    /// is the intended entry point for wiring the full 9-slot scheme.
205    #[must_use] pub fn slot_index(
206        width_known: bool,
207        height_known: bool,
208        width_type: AvailableWidthType,
209        height_type: AvailableWidthType,
210    ) -> usize {
211        match (width_known, height_known) {
212            (true, true) => 0,
213            (true, false) => {
214                if width_type == AvailableWidthType::MinContent { 2 } else { 1 }
215            }
216            (false, true) => {
217                if height_type == AvailableWidthType::MinContent { 4 } else { 3 }
218            }
219            (false, false) => {
220                let w = usize::from(width_type == AvailableWidthType::MinContent);
221                let h = usize::from(height_type == AvailableWidthType::MinContent);
222                5 + w * 2 + h
223            }
224        }
225    }
226
227    /// Look up a sizing cache entry, implementing Taffy's "result matches request"
228    /// optimization: if the caller provides the result size as a known dimension
229    /// (common in Pass1→Pass2 transitions), it's still a cache hit.
230    #[must_use] pub fn get_size(&self, slot: usize, known_dims: LogicalSize) -> Option<&SizingCacheEntry> {
231        let entry = self.measure_entries[slot].as_ref()?;
232        // Exact match on input constraints
233        if (known_dims.width - entry.available_size.width).abs() < CACHE_SIZE_EPSILON
234            && (known_dims.height - entry.available_size.height).abs() < CACHE_SIZE_EPSILON
235        {
236            return Some(entry);
237        }
238        // "Result matches request" — if the caller provides the result size
239        // as a known dimension, it's still a hit. This is the key optimization
240        // that makes two-pass layout O(n): Pass 1 measures a node, Pass 2
241        // provides the measured size as a constraint → automatic cache hit.
242        if (known_dims.width - entry.result_size.width).abs() < CACHE_SIZE_EPSILON
243            && (known_dims.height - entry.result_size.height).abs() < CACHE_SIZE_EPSILON
244        {
245            return Some(entry);
246        }
247        None
248    }
249
250    /// Store a sizing result in the given slot.
251    pub const fn store_size(&mut self, slot: usize, entry: SizingCacheEntry) {
252        self.measure_entries[slot] = Some(entry);
253        self.is_empty = false;
254    }
255
256    /// Look up the full layout cache entry.
257    #[must_use] pub fn get_layout(&self, known_dims: LogicalSize) -> Option<&LayoutCacheEntry> {
258        let entry = self.layout_entry.as_ref()?;
259        if (known_dims.width - entry.available_size.width).abs() < CACHE_SIZE_EPSILON
260            && (known_dims.height - entry.available_size.height).abs() < CACHE_SIZE_EPSILON
261        {
262            return Some(entry);
263        }
264        // "Result matches request" for layout too
265        if (known_dims.width - entry.result_size.width).abs() < CACHE_SIZE_EPSILON
266            && (known_dims.height - entry.result_size.height).abs() < CACHE_SIZE_EPSILON
267        {
268            return Some(entry);
269        }
270        None
271    }
272
273    /// Store a full layout result.
274    pub fn store_layout(&mut self, entry: LayoutCacheEntry) {
275        self.layout_entry = Some(entry);
276        self.is_empty = false;
277    }
278}
279
280/// External layout cache, parallel to `LayoutTree.nodes`.
281///
282/// `cache_map.entries[i]` holds the cache for `LayoutTree.nodes[i]`.
283/// Stored on `LayoutCache` (persists across frames).
284///
285/// This is Azul's improvement over Taffy's on-node cache:
286/// - `LayoutNode` stays slim (0 bytes overhead)
287/// - No `&mut tree` needed to read/write cache entries
288/// - Cache can be resized independently after reconciliation
289/// - O(1) indexed lookup (Vec) instead of O(log n) (`BTreeMap`)
290#[derive(Debug, Clone, Default)]
291pub struct LayoutCacheMap {
292    pub entries: Vec<NodeCache>,
293}
294
295impl LayoutCacheMap {
296    /// Resize to match tree length after reconciliation.
297    /// New nodes get empty (dirty) caches. Removed nodes' caches are dropped.
298    pub fn resize_to_tree(&mut self, tree_len: usize) {
299        self.entries.resize_with(tree_len, NodeCache::default);
300    }
301
302    /// O(1) lookup by layout tree index.
303    #[inline]
304    #[must_use] pub fn get(&self, node_index: usize) -> &NodeCache {
305        &self.entries[node_index]
306    }
307
308    /// O(1) mutable lookup by layout tree index.
309    #[inline]
310    pub fn get_mut(&mut self, node_index: usize) -> &mut NodeCache {
311        &mut self.entries[node_index]
312    }
313
314    /// Invalidate a node and propagate dirty flags upward through ancestors.
315    ///
316    /// Implements Taffy's early-stop optimization: propagation halts at the
317    /// first ancestor whose cache is already empty (i.e., already dirty).
318    /// This prevents redundant O(depth) propagation when multiple children
319    /// of the same parent are dirtied.
320    pub fn mark_dirty(&mut self, node_index: usize, tree: &[LayoutNodeHot]) {
321        if node_index >= self.entries.len() {
322            return;
323        }
324        let cache = &mut self.entries[node_index];
325        if cache.is_empty {
326            return; // Already dirty → ancestors are too
327        }
328        cache.clear();
329
330        // Propagate upward (Taffy's early-stop optimization)
331        let mut current = tree.get(node_index).and_then(|n| n.parent);
332        while let Some(parent_idx) = current {
333            if parent_idx >= self.entries.len() {
334                break;
335            }
336            let parent_cache = &mut self.entries[parent_idx];
337            if parent_cache.is_empty {
338                break; // Stop early — ancestor already dirty
339            }
340            parent_cache.clear();
341            current = tree.get(parent_idx).and_then(|n| n.parent);
342        }
343    }
344}
345
346/// The persistent cache that holds the layout state between frames.
347#[derive(Debug, Clone, Default)]
348pub struct LayoutCache {
349    /// The fully laid-out tree from the previous frame. This is our primary cache.
350    pub tree: Option<LayoutTree>,
351    /// The final, absolute positions of all nodes from the previous frame.
352    pub calculated_positions: super::PositionVec,
353    /// The viewport size from the last layout pass, used to detect resizes.
354    pub viewport: Option<LogicalRect>,
355    /// Stable scroll IDs computed from `node_data_hash` (layout index -> scroll ID)
356    pub scroll_ids: HashMap<usize, u64>,
357    /// Mapping from scroll ID to DOM `NodeId` for hit testing
358    pub scroll_id_to_node_id: HashMap<u64, NodeId>,
359    /// CSS counter values for each node and counter name.
360    /// Key: (`layout_index`, `counter_name`), Value: counter value
361    /// This stores the computed counter values after processing counter-reset and
362    /// counter-increment.
363    pub counters: HashMap<(usize, String), i32>,
364    /// Cache of positioned floats for each BFC node (`layout_index` -> `FloatingContext`).
365    /// This persists float positions across multiple layout passes, ensuring IFC
366    /// children always have access to correct float exclusions even when layout is
367    /// recalculated.
368    pub float_cache: HashMap<usize, fc::FloatingContext>,
369    /// Per-node multi-slot cache (inspired by Taffy's 9+1 architecture).
370    /// External to `LayoutTree` — indexed by node index for O(1) lookup.
371    /// Persists across frames; resized after reconciliation.
372    pub cache_map: LayoutCacheMap,
373    /// Snapshot of `calculated_positions` from the previous frame, used by the
374    /// compositor to compute damage rects (old bounds vs new bounds).
375    pub previous_positions: super::PositionVec,
376    /// Cached display list keyed by `(root_subtree_hash, viewport)`.
377    /// When the reconciled tree has the same root `subtree_hash` AND
378    /// the same viewport as the cached one, the display list is
379    /// returned as-is — skipping layout, positioning, and
380    /// display-list generation entirely. Cleared whenever
381    /// `mark_dirty` fires on any node (since the root's upstream
382    /// invalidation chain clears its ancestors).
383    pub cached_display_list: Option<(SubtreeHash, LogicalRect, super::display_list::DisplayList)>,
384    /// Raw pointer of the `StyledDom` from the previous layout pass. When the
385    /// same `&StyledDom` reference is passed again AND the viewport is unchanged,
386    /// skip reconcile entirely and return the cached display list (saves ~0.8 ms).
387    pub prev_dom_ptr: usize,
388    pub prev_viewport: LogicalRect,
389}
390
391/// Approximate heap-byte breakdown of the solver3 `LayoutCache`.
392#[derive(Copy, Debug, Clone, Default)]
393pub struct Solver3CacheMemoryReport {
394    pub tree_bytes: usize,
395    pub tree_report: Option<super::layout_tree::LayoutTreeMemoryReport>,
396    pub calculated_positions_bytes: usize,
397    pub previous_positions_bytes: usize,
398    pub scroll_ids_bytes: usize,
399    pub scroll_id_to_node_id_bytes: usize,
400    pub counters_bytes: usize,
401    pub float_cache_bytes: usize,
402    pub cache_map_bytes: usize,
403    pub cached_display_list_bytes: usize,
404}
405
406impl Solver3CacheMemoryReport {
407    #[must_use] pub const fn total_bytes(&self) -> usize {
408        self.tree_bytes
409            + self.calculated_positions_bytes
410            + self.previous_positions_bytes
411            + self.scroll_ids_bytes
412            + self.scroll_id_to_node_id_bytes
413            + self.counters_bytes
414            + self.float_cache_bytes
415            + self.cache_map_bytes
416            + self.cached_display_list_bytes
417    }
418}
419
420impl LayoutCache {
421    /// Drop all incremental-reuse state so the next `layout_document` lays the
422    /// DOM out from scratch (cold path), as if no previous frame existed.
423    ///
424    /// Required before laying out a DOM whose `NodeIds` are NOT a stable evolution
425    /// of whatever this (shared) cache last held — namely `VirtualView` / iframe
426    /// child DOMs, which their callbacks rebuild wholesale on every invocation.
427    /// Incremental reconciliation matches/reuses subtrees by `NodeId` + subtree
428    /// hash; on a wholesale rebuild those `NodeIds` are reassigned, so reusing the
429    /// prior tree can graft `NodeIds` that no longer exist in the new `StyledDom`
430    /// (panic: out-of-bounds `node_data` index when the DOM shrinks — e.g. the map
431    /// dropping tiles on zoom-out).
432    pub fn reset_incremental(&mut self) {
433        self.tree = None;
434        self.cache_map = LayoutCacheMap::default();
435        self.cached_display_list = None;
436        self.prev_dom_ptr = 0;
437        self.counters.clear();
438        self.float_cache.clear();
439    }
440
441    /// Approximate heap bytes retained by this `LayoutCache`.
442    #[must_use] pub fn memory_report(&self) -> Solver3CacheMemoryReport {
443        let tree_report = self.tree.as_ref().map(LayoutTree::memory_report);
444        let tree_bytes = tree_report.as_ref().map_or(0, super::layout_tree::LayoutTreeMemoryReport::total_bytes);
445        // cache_map: Vec<NodeCache>; NodeCache has 9 Option<SizingCacheEntry>
446        // + 1 Option<LayoutCacheEntry>. Count filled layout entries' child_positions.
447        let mut cache_map_bytes = self.cache_map.entries.capacity()
448            * size_of::<NodeCache>();
449        for e in &self.cache_map.entries {
450            if let Some(le) = &e.layout_entry {
451                cache_map_bytes += le.child_positions.capacity()
452                    * size_of::<(usize, LogicalPosition)>();
453            }
454        }
455        Solver3CacheMemoryReport {
456            tree_bytes,
457            tree_report,
458            calculated_positions_bytes: self.calculated_positions.len()
459                * size_of::<LogicalPosition>(),
460            previous_positions_bytes: self.previous_positions.len()
461                * size_of::<LogicalPosition>(),
462            scroll_ids_bytes: self.scroll_ids.len()
463                * (size_of::<usize>() + size_of::<u64>()),
464            scroll_id_to_node_id_bytes: self.scroll_id_to_node_id.len()
465                * (size_of::<u64>() + size_of::<NodeId>()),
466            counters_bytes: self.counters.iter().map(|((_, name), _)| {
467                size_of::<(usize, String)>()
468                    + size_of::<i32>()
469                    + name.capacity()
470            }).sum(),
471            float_cache_bytes: self.float_cache.len() * 256, // conservative per-FC
472            cache_map_bytes,
473            cached_display_list_bytes: if self.cached_display_list.is_some() { 2048 } else { 0 },
474        }
475    }
476}
477
478/// The result of a reconciliation pass.
479#[derive(Debug, Default)]
480pub struct ReconciliationResult {
481    /// Set of nodes whose intrinsic size needs to be recalculated (bottom-up pass).
482    pub intrinsic_dirty: BTreeSet<usize>,
483    /// Set of layout roots whose subtrees need a new top-down layout pass.
484    pub layout_roots: BTreeSet<usize>,
485    /// Set of nodes that only need a paint/display-list update (no relayout).
486    pub paint_dirty: BTreeSet<usize>,
487}
488
489impl ReconciliationResult {
490    /// Checks if any layout or paint work is needed.
491    #[must_use] pub fn is_clean(&self) -> bool {
492        self.intrinsic_dirty.is_empty()
493            && self.layout_roots.is_empty()
494            && self.paint_dirty.is_empty()
495    }
496
497    /// Returns true if full layout work is needed for at least one node.
498    #[must_use] pub fn needs_layout(&self) -> bool {
499        !self.intrinsic_dirty.is_empty() || !self.layout_roots.is_empty()
500    }
501
502    /// Returns true if only paint work is needed (no layout).
503    #[must_use] pub fn needs_paint_only(&self) -> bool {
504        !self.needs_layout() && !self.paint_dirty.is_empty()
505    }
506}
507
508/// After dirty subtrees are laid out, this repositions their clean siblings
509/// without recalculating their internal layout. This is a critical optimization.
510///
511/// This function acts as a dispatcher, inspecting the parent's formatting context
512/// and calling the appropriate repositioning algorithm. For complex layout modes
513/// like Flexbox or Grid, this optimization is skipped, as a full relayout is
514/// often required to correctly recalculate spacing and sizing for all siblings.
515#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
516pub fn reposition_clean_subtrees(
517    styled_dom: &StyledDom,
518    tree: &LayoutTree,
519    layout_roots: &BTreeSet<usize>,
520    calculated_positions: &mut super::PositionVec,
521) {
522    // Find the unique parents of all dirty layout roots. These are the containers
523    // where sibling positions need to be adjusted.
524    let mut parents_to_reposition = BTreeSet::new();
525    for &root_idx in layout_roots {
526        if let Some(parent_idx) = tree.get(root_idx).and_then(|n| n.parent) {
527            parents_to_reposition.insert(parent_idx);
528        }
529    }
530
531    for parent_idx in parents_to_reposition {
532        let Some(parent_node) = tree.get(parent_idx) else {
533            continue;
534        };
535
536        // Dispatch to the correct repositioning logic based on the parent's layout mode.
537        match parent_node.formatting_context {
538            // Cases that use simple block-flow stacking can be optimized.
539            FormattingContext::Block { .. } | FormattingContext::TableRowGroup => {
540                reposition_block_flow_siblings(
541                    styled_dom,
542                    parent_idx,
543                    tree,
544                    layout_roots,
545                    calculated_positions,
546                );
547            }
548
549            FormattingContext::Flex | FormattingContext::Grid => {
550                // Taffy handles this, so if a child is dirty, the parent would have
551                // already been marked as a layout_root and re-laid out by Taffy.
552                // We do nothing here for Flex or Grid.
553            }
554
555            FormattingContext::Table | FormattingContext::TableRow => {
556                // TODO: Table layout is interdependent. A change in one cell's size
557                // can affect the entire column's width or row's height, requiring a
558                // full relayout of the table. This optimization is skipped.
559            }
560
561            // Other contexts either don't contain children in a way that this
562            // optimization applies (e.g., Inline, TableCell) or are handled by other
563            // layout mechanisms (e.g., OutOfFlow).
564            _ => { /* Do nothing */ }
565        }
566    }
567}
568
569/// Convert `LayoutOverflow` to `OverflowBehavior`
570/// CSS Overflow Module Level 3: initial value of `overflow` is `visible`.
571// +spec:overflow:3a6297 - initial value 'visible', maps hidden/scroll/auto overflow behaviors
572fn to_overflow_behavior(overflow: MultiValue<LayoutOverflow>) -> fc::OverflowBehavior {
573    match overflow.unwrap_or(LayoutOverflow::Visible) {
574        LayoutOverflow::Visible => fc::OverflowBehavior::Visible,
575        LayoutOverflow::Hidden | LayoutOverflow::Clip => fc::OverflowBehavior::Hidden,
576        LayoutOverflow::Scroll => fc::OverflowBehavior::Scroll,
577        LayoutOverflow::Auto => fc::OverflowBehavior::Auto,
578    }
579}
580
581/// Convert `StyleTextAlign` to `fc::TextAlign`
582// +spec:text-alignment-spacing:43ea0a - text-align-all shorthand: aligns all lines except last (overridden by text-align-last)
583const fn style_text_align_to_fc(text_align: StyleTextAlign) -> fc::TextAlign {
584    match text_align {
585        StyleTextAlign::Start | StyleTextAlign::Left => fc::TextAlign::Start,
586        StyleTextAlign::End | StyleTextAlign::Right => fc::TextAlign::End,
587        StyleTextAlign::Center => fc::TextAlign::Center,
588        StyleTextAlign::Justify => fc::TextAlign::Justify,
589    }
590}
591
592/// Collects DOM child IDs from the node hierarchy into a Vec.
593///
594/// This is a helper function that flattens the sibling iteration into a simple loop.
595/// Children with `display: none` are filtered out since they generate no boxes.
596#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/counter/fixed-point cast
597#[must_use] pub fn collect_children_dom_ids(styled_dom: &StyledDom, parent_dom_id: NodeId) -> Vec<NodeId> {
598    let hierarchy_container = styled_dom.node_hierarchy.as_container();
599    let mut children = Vec::new();
600
601    let Some(hierarchy_item) = hierarchy_container.get(parent_dom_id) else {
602        return children;
603    };
604
605    let Some(mut child_id) = hierarchy_item.first_child_id(parent_dom_id) else {
606        // DEBUG (2026-06-02 children-None): first_child_id returned None for this
607        // parent → 0xC0000000 marker @0x40540+parent*4. REVERT before commit.
608        unsafe {
609            let pi = parent_dom_id.index();
610            if pi < 8 { crate::az_mark((0x40540 + pi * 4) as u32, (0xC000_0000u32)); }
611        }
612        return children;
613    };
614
615    // +spec:display-property:9f02c6 - display:none elements generate no boxes
616    // +spec:display-property:3b507e - display:none excludes subtree from box tree
617    if get_display_type(styled_dom, child_id) != LayoutDisplay::None {
618        children.push(child_id);
619    }
620    while let Some(hierarchy_item) = hierarchy_container.get(child_id) {
621        let Some(next) = hierarchy_item.next_sibling_id() else {
622            break;
623        };
624        if get_display_type(styled_dom, next) != LayoutDisplay::None {
625            children.push(next);
626        }
627        child_id = next;
628    }
629
630    // DEBUG (2026-06-02 children-None): record collected child count per parent
631    // @0x40540+parent*4 (0xCC00_00NN). N=0 with first_child Some ⇒ get_display_type
632    // mis-lift skipped them; N>0 ⇒ walk works. REVERT before commit.
633    unsafe {
634        let pi = parent_dom_id.index();
635        if pi < 8 {
636            crate::az_mark((0x40540 + pi * 4) as u32, (0xCC00_0000u32 | (children.len() as u32 & 0xffff)));
637        }
638    }
639    children
640}
641
642/// Repositions clean children within a simple block-flow layout (like a BFC or a
643/// table-row-group). It stacks children along the main axis, preserving their
644/// previously calculated cross-axis alignment.
645pub fn reposition_block_flow_siblings(
646    styled_dom: &StyledDom,
647    parent_idx: usize,
648    tree: &LayoutTree,
649    layout_roots: &BTreeSet<usize>,
650    calculated_positions: &mut super::PositionVec,
651) {
652    let Some(parent_node) = tree.get(parent_idx) else {
653        return;
654    };
655    let dom_id = parent_node.dom_node_id.unwrap_or(NodeId::ZERO);
656    let styled_node_state = styled_dom
657        .styled_nodes
658        .as_container()
659        .get(dom_id)
660        .map(|n| n.styled_node_state)
661        .unwrap_or_default();
662
663    let writing_mode = get_writing_mode(styled_dom, dom_id, &styled_node_state).unwrap_or_default();
664
665    let parent_pos = calculated_positions
666        .get(parent_idx)
667        .copied()
668        .unwrap_or_default();
669
670    let parent_bp = parent_node.box_props.unpack();
671    let content_box_origin = LogicalPosition::new(
672        parent_pos.x + parent_bp.padding.left,
673        parent_pos.y + parent_bp.padding.top,
674    );
675
676    let mut main_pen = 0.0;
677
678    for &child_idx in tree.children(parent_idx) {
679        let Some(child_node) = tree.get(child_idx) else {
680            continue;
681        };
682
683        let child_size = child_node.used_size.unwrap_or_default();
684        let child_bp = child_node.box_props.unpack();
685        let child_main_sum = child_bp.margin.main_sum(writing_mode);
686        let margin_box_main_size = child_size.main(writing_mode) + child_main_sum;
687
688        if layout_roots.contains(&child_idx) {
689            // This child was DIRTY and has been correctly repositioned.
690            // Update the pen to the position immediately after this child.
691            let new_pos = match calculated_positions.get(child_idx) {
692                Some(p) => *p,
693                None => continue,
694            };
695
696            let main_axis_offset = if writing_mode.is_vertical() {
697                new_pos.x - content_box_origin.x
698            } else {
699                new_pos.y - content_box_origin.y
700            };
701
702            main_pen = main_axis_offset
703                + child_size.main(writing_mode)
704                + child_bp.margin.main_end(writing_mode);
705        } else {
706            // This child is *clean*. Calculate its new position and shift its
707            // entire subtree.
708            let old_pos = match calculated_positions.get(child_idx) {
709                Some(p) => *p,
710                None => continue,
711            };
712
713            let child_main_start = child_bp.margin.main_start(writing_mode);
714            let new_main_pos = main_pen + child_main_start;
715            let old_relative_pos = tree.warm(child_idx)
716                .and_then(|w| w.relative_position)
717                .unwrap_or_default();
718            let cross_pos = if writing_mode.is_vertical() {
719                old_relative_pos.y
720            } else {
721                old_relative_pos.x
722            };
723            let new_relative_pos =
724                LogicalPosition::from_main_cross(new_main_pos, cross_pos, writing_mode);
725
726            let new_absolute_pos = LogicalPosition::new(
727                content_box_origin.x + new_relative_pos.x,
728                content_box_origin.y + new_relative_pos.y,
729            );
730
731            if old_pos != new_absolute_pos {
732                let delta = LogicalPosition::new(
733                    new_absolute_pos.x - old_pos.x,
734                    new_absolute_pos.y - old_pos.y,
735                );
736                shift_subtree_position(child_idx, delta, tree, calculated_positions);
737            }
738
739            main_pen += margin_box_main_size;
740        }
741    }
742}
743
744/// Helper to recursively shift the absolute position of a node and all its descendants.
745fn shift_subtree_position(
746    node_idx: usize,
747    delta: LogicalPosition,
748    tree: &LayoutTree,
749    calculated_positions: &mut super::PositionVec,
750) {
751    if let Some(pos) = calculated_positions.get_mut(node_idx) {
752        pos.x += delta.x;
753        pos.y += delta.y;
754    }
755
756    if let Some(node) = tree.get(node_idx) {
757        let children = tree.children(node_idx).to_vec();
758        for &child_idx in &children {
759            shift_subtree_position(child_idx, delta, tree, calculated_positions);
760        }
761    }
762}
763
764/// Compares the new DOM against the cached tree, creating a new tree
765/// and identifying which parts need to be re-laid out.
766/// Count how many of the supplied DOM children would actually end up
767/// in the layout tree. Mirrors the filters applied by
768/// `LayoutTreeBuilder::build_recursive` so reconciliation can compare
769/// like-for-like:
770///
771/// - `display: none` nodes are skipped entirely.
772/// - In table structural contexts (table, row-group, row) whitespace
773///   text nodes are skipped (CSS 2.2 §17.2.1, matches
774///   `should_skip_for_table_structure`).
775/// - Whitespace-only inline runs that sit between block siblings
776///   collapse to zero boxes (CSS 2.2 §9.2.2.1).
777///
778/// The first two rules drop children unconditionally; the third only
779/// fires on siblings surrounding a block-level child, so we detect it
780/// by walking the run pairs. We do not build the runs — just count
781/// survivors.
782fn layout_relevant_child_count(
783    styled_dom: &StyledDom,
784    children: &[NodeId],
785    parent_id: NodeId,
786) -> usize {
787    use super::getters::{get_display_property, MultiValue};
788    use super::layout_tree::{is_block_level, is_whitespace_only_text};
789
790    let parent_display = match get_display_property(styled_dom, Some(parent_id)) {
791        MultiValue::Exact(d) => d,
792        _ => LayoutDisplay::Block,
793    };
794    let is_table_structural = matches!(
795        parent_display,
796        LayoutDisplay::Table
797            | LayoutDisplay::InlineTable
798            | LayoutDisplay::TableRowGroup
799            | LayoutDisplay::TableHeaderGroup
800            | LayoutDisplay::TableFooterGroup
801            | LayoutDisplay::TableRow
802    );
803
804    let has_any_block_child = children
805        .iter()
806        .any(|&id| is_block_level(styled_dom, id));
807
808    let mut count = 0usize;
809    // When parent has any block child, whitespace-only inline runs
810    // surrounding blocks collapse. We approximate that by skipping
811    // whitespace text whenever any block sibling exists.
812    let collapse_inline_whitespace = has_any_block_child;
813    for &id in children {
814        // display:none drops
815        let display = match get_display_property(styled_dom, Some(id)) {
816            MultiValue::Exact(d) => d,
817            _ => LayoutDisplay::Block,
818        };
819        if matches!(display, LayoutDisplay::None) {
820            continue;
821        }
822        // Table-structural whitespace drops.
823        if is_table_structural && is_whitespace_only_text(styled_dom, id) {
824            continue;
825        }
826        // Whitespace-only inline run collapse when mixed with blocks.
827        if collapse_inline_whitespace
828            && !is_block_level(styled_dom, id)
829            && is_whitespace_only_text(styled_dom, id)
830        {
831            continue;
832        }
833        count += 1;
834    }
835    count
836}
837
838/// # Errors
839///
840/// Returns a `LayoutError` if layout reconciliation fails.
841pub fn reconcile_and_invalidate<T: ParsedFontTrait>(
842    ctx: &mut LayoutContext<'_, T>,
843    cache: &LayoutCache,
844    viewport: LogicalRect,
845) -> Result<(LayoutTree, ReconciliationResult)> {
846    let _probe_outer = crate::probe::Probe::span("reconcile_and_invalidate");
847    let mut new_tree_builder = LayoutTreeBuilder::new(ctx.viewport_size);
848    let mut recon_result = ReconciliationResult::default();
849    // A viewport SIZE change invalidates every computed size: percentage, flex,
850    // and absolute insets (top/right/bottom/left) all resolve against the
851    // viewport / containing block. Incrementally reusing the cached layout tree
852    // left out-of-flow and VirtualView nodes sized against the OLD viewport — e.g.
853    // the map's absolutely-positioned container kept its old size, so a maximized
854    // window showed tiles only in the original rect and grey everywhere else
855    // (#9 "grey on resize"). On a size change, drop the cached tree so the whole
856    // tree is laid out fresh against the new viewport. (Position-only moves keep
857    // the incremental path.)
858    let viewport_resized = cache.viewport.is_none_or(|v| v.size != viewport.size);
859    let old_tree = if viewport_resized {
860        None
861    } else {
862        cache.tree.as_ref()
863    };
864
865    if viewport_resized {
866        recon_result.layout_roots.insert(0); // Root is always index 0
867    }
868
869    let root_dom_id = ctx
870        .styled_dom
871        .root
872        .into_crate_internal()
873        .unwrap_or(NodeId::ZERO);
874    let root_idx = reconcile_recursive(
875        ctx.styled_dom,
876        root_dom_id,
877        old_tree.map(|t| t.root),
878        None,
879        old_tree,
880        &mut new_tree_builder,
881        &mut recon_result,
882        ctx.debug_messages,
883    )?;
884
885    // Clean up layout roots: if a parent is a layout root, its children don't need to be.
886    let final_layout_roots = recon_result
887        .layout_roots
888        .iter()
889        .filter(|&&idx| {
890            let mut current = new_tree_builder.get(idx).and_then(|n| n.parent);
891            while let Some(p_idx) = current {
892                if recon_result.layout_roots.contains(&p_idx) {
893                    return false;
894                }
895                current = new_tree_builder.get(p_idx).and_then(|n| n.parent);
896            }
897            true
898        })
899        .copied()
900        .collect();
901    recon_result.layout_roots = final_layout_roots;
902
903    let new_tree = new_tree_builder.build(root_idx);
904    // layout_document's step marker is stuck at 1 (post-`?` not reached), the
905    // lifted `?` mis-discriminated this Ok as Err (niche-Result mis-lift).
906    { let _ = (0xCC00_0001u32); }
907    Ok((new_tree, recon_result))
908}
909
910/// CSS 2.2 § 9.2.2.1: Checks whether an inline run consists entirely of
911/// whitespace-only text nodes, in which case it should NOT generate an
912/// anonymous IFC wrapper in a BFC mixed-content context.
913///
914/// This prevents whitespace between block elements from creating empty
915/// anonymous blocks that take up vertical space (regression c33e94b0).
916///
917/// Exception: if the parent (or any ancestor) has `white-space: pre`,
918/// `pre-wrap`, or `pre-line`, whitespace IS significant and the wrapper
919/// must still be created.
920fn is_whitespace_only_inline_run(
921    styled_dom: &StyledDom,
922    inline_run: &[(usize, NodeId)],
923    parent_dom_id: NodeId,
924) -> bool {
925    use azul_css::props::style::text::StyleWhiteSpace;
926
927    if inline_run.is_empty() {
928        return true;
929    }
930
931    // Check if the parent preserves whitespace
932    let parent_state = &styled_dom.styled_nodes.as_container()[parent_dom_id].styled_node_state;
933    let white_space = match get_white_space_property(styled_dom, parent_dom_id, parent_state) {
934        MultiValue::Exact(ws) => Some(ws),
935        _ => None,
936    };
937
938    // If white-space preserves whitespace, don't strip
939    if matches!(
940        white_space,
941        Some(StyleWhiteSpace::Pre | StyleWhiteSpace::PreWrap |
942StyleWhiteSpace::PreLine)
943    ) {
944        return false;
945    }
946
947    // Check that every node in the run is a whitespace-only text node
948    let binding = styled_dom.node_data.as_container();
949    for &(_, dom_id) in inline_run {
950        if let Some(data) = binding.get(dom_id) {
951            match data.get_node_type() {
952                NodeType::Text(text) => {
953                    let s = text.as_str();
954                    if !s.chars().all(|c| matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0C')) {
955                        return false; // Non-whitespace text → must create wrapper
956                    }
957                }
958                _ => {
959                    return false; // Non-text inline element → must create wrapper
960                }
961            }
962        }
963    }
964
965    true // All nodes are whitespace-only text
966}
967
968/// Recursively traverses the new DOM and old tree, building a new tree and marking dirty nodes.
969#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/counter/fixed-point cast
970#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
971/// # Errors
972///
973/// Returns a `LayoutError` if recursive reconciliation fails.
974pub fn reconcile_recursive(
975    styled_dom: &StyledDom,
976    new_dom_id: NodeId,
977    old_tree_idx: Option<usize>,
978    new_parent_idx: Option<usize>,
979    old_tree: Option<&LayoutTree>,
980    new_tree_builder: &mut LayoutTreeBuilder,
981    recon: &mut ReconciliationResult,
982    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
983) -> Result<usize> {
984    // Cache the env check in a `OnceLock<bool>`: this branch
985    // fires once per dirty node (hundreds on cold layout),
986    // and a direct `env::var` is a mutex + hashmap lookup
987    // on macOS (~100 ns/call) even when the env var is unset.
988    static FP_DUMP_ENABLED: std::sync::OnceLock<bool> =
989        std::sync::OnceLock::new();
990    let node_data = &styled_dom.node_data.as_container()[new_dom_id];
991
992    let old_cold = old_tree.and_then(|t| old_tree_idx.and_then(|idx| t.cold(idx)));
993    match (old_tree.is_some(), old_tree_idx.is_some(), old_cold.is_some()) {
994        (false, _, _) => drop(crate::probe::Probe::span("recon_old_tree_none")),
995        (true, false, _) => drop(crate::probe::Probe::span("recon_old_idx_none")),
996        (true, true, false) => drop(crate::probe::Probe::span("recon_cold_none")),
997        (true, true, true) => drop(crate::probe::Probe::span("recon_cold_some")),
998    }
999
1000    // Compute the new multi-field fingerprint instead of a single hash.
1001    let new_fingerprint = {
1002        let _p = crate::probe::Probe::span("fingerprint_compute");
1003        NodeDataFingerprint::compute(
1004            node_data,
1005            styled_dom.styled_nodes.as_container().get(new_dom_id).map(|n| &n.styled_node_state),
1006        )
1007    };
1008
1009    // Compare fingerprints to determine what changed (Layout, Paint, or Nothing).
1010    let dirty_flag = old_cold.map_or_else(|| {
1011            drop(crate::probe::Probe::span("fp_new_node"));
1012            DirtyFlag::Layout // new node → full layout
1013        }, |old_c| {
1014            let change_set = old_c.node_data_fingerprint.diff(&new_fingerprint);
1015            if change_set.needs_layout() {
1016                drop(crate::probe::Probe::span("fp_needs_layout"));
1017                let enabled = *FP_DUMP_ENABLED.get_or_init(|| {
1018                    std::env::var_os("AZ_FP_DUMP").is_some()
1019                });
1020                if enabled {
1021                    use std::sync::atomic::{AtomicUsize, Ordering};
1022                    static DUMPED: AtomicUsize = AtomicUsize::new(0);
1023                    let n = DUMPED.fetch_add(1, Ordering::Relaxed);
1024                    if n < 10 {
1025                        eprintln!(
1026                            "[fp_diff {n}] dom={} old={:?} new={:?}",
1027                            new_dom_id.index(),
1028                            old_c.node_data_fingerprint,
1029                            new_fingerprint,
1030                        );
1031                    }
1032                }
1033                DirtyFlag::Layout
1034            } else if change_set.needs_paint() {
1035                drop(crate::probe::Probe::span("fp_needs_paint"));
1036                DirtyFlag::Paint
1037            } else {
1038                drop(crate::probe::Probe::span("fp_clean"));
1039                DirtyFlag::None
1040            }
1041        });
1042    let is_dirty = dirty_flag >= DirtyFlag::Paint;
1043
1044    // M12.7: `|| old_tree.is_none()` — on COLD layout there is no old tree to
1045    // clone, so we MUST create a fresh node; taking the else-branch would hit
1046    // `ok_or(InvalidTree)` on a None old_tree. This is both semantically correct
1047    // AND robust against a mis-lifted `dirty_flag`/Option match (the suspected
1048    // niche-enum mis-discriminant) wrongly steering cold nodes into the else.
1049    let new_node_idx = if dirty_flag >= DirtyFlag::Layout || old_tree.is_none() {
1050        { let _ = (0xBB00_0001u32); }
1051        let idx = new_tree_builder.create_node_from_dom(
1052            styled_dom,
1053            new_dom_id,
1054            new_parent_idx,
1055            debug_messages,
1056        );
1057        // Blockify replaced/inline flex-or-grid items (CSS Display 3 §2.7). The
1058        // full `process_node` build does this; this incremental path called
1059        // `create_node_from_dom` directly and skipped it, so a flex-item <img>
1060        // (e.g. the AzulPaint canvas) stayed inline and ignored flex-grow.
1061        new_tree_builder.blockify_node_display(styled_dom, new_dom_id, idx, new_parent_idx);
1062        idx
1063    } else {
1064        { let _ = (0xBB00_0002u32); }
1065        // Paint-only or clean: clone the old node (preserving layout cache)
1066        let old_full_node = old_tree
1067            .and_then(|t| old_tree_idx.and_then(|idx| t.get_full_node(idx)))
1068            .ok_or(LayoutError::InvalidTree)?;
1069        let mut idx = new_tree_builder.clone_node_from_old(&old_full_node, new_parent_idx);
1070        // If paint-only change, update the fingerprint and dirty flag
1071        if dirty_flag == DirtyFlag::Paint {
1072            if let Some(cloned) = new_tree_builder.get_mut(idx) {
1073                cloned.node_data_fingerprint = new_fingerprint;
1074                cloned.dirty_flag = DirtyFlag::Paint;
1075            }
1076        }
1077        idx
1078    };
1079
1080    // reconcile_recursive sees it. 0 = correct (the first node); 64 (matching the
1081    // build-marker root_idx) = the usize return mis-reads here.
1082    { let _ = (0xAB00_0000u32 | (new_node_idx as u32 & 0xffff)); }
1083
1084    // CRITICAL: For list-items, create a ::marker pseudo-element as the first child
1085    // This must be done after the node is created but before processing children
1086    // Per CSS Lists Module Level 3, ::marker is generated as the first child of list-items
1087    {
1088        use crate::solver3::getters::get_display_property;
1089        let display = get_display_property(styled_dom, Some(new_dom_id))
1090            .exact();
1091
1092        if matches!(display, Some(LayoutDisplay::ListItem)) {
1093            // Create ::marker pseudo-element for this list-item
1094            new_tree_builder.create_marker_pseudo_element(styled_dom, new_dom_id, new_node_idx);
1095        }
1096    }
1097
1098    // Reconcile children to check for structural changes and build the new tree structure.
1099    let mut new_children_dom_ids: Vec<_> = collect_children_dom_ids(styled_dom, new_dom_id);
1100
1101    // CSS 2.2 §17.2.1: Filter whitespace-only text nodes from table structural elements
1102    // (table, row-group, row). Without this, the reconciler sees them as "inline" children
1103    // mixed with block-level <td>/<th>, triggering incorrect anonymous IFC wrapping.
1104    // The layout tree builder already does this via should_skip_for_table_structure().
1105    {
1106        use super::getters::{get_display_property, MultiValue};
1107        let parent_display = match get_display_property(styled_dom, Some(new_dom_id)) {
1108            MultiValue::Exact(d) => d,
1109            _ => LayoutDisplay::Block,
1110        };
1111        if matches!(parent_display,
1112            LayoutDisplay::Table
1113            | LayoutDisplay::InlineTable
1114            | LayoutDisplay::TableRowGroup
1115            | LayoutDisplay::TableHeaderGroup
1116            | LayoutDisplay::TableFooterGroup
1117            | LayoutDisplay::TableRow
1118        ) {
1119            new_children_dom_ids.retain(|&id| {
1120                !super::layout_tree::is_whitespace_only_text(styled_dom, id)
1121            });
1122        }
1123    }
1124
1125    // Compute both positional and DOM-keyed lookups for the old
1126    // tree's children. The DOM-keyed map is authoritative for
1127    // reconciliation (positional drifts every time the layout-tree
1128    // builder drops a DOM child — whitespace text, display:none,
1129    // table-structural whitespace — or inserts an anonymous
1130    // wrapper that isn't in the DOM).
1131    let old_children_indices: Vec<usize> = old_tree
1132        .and_then(|t| old_tree_idx.map(|idx| t.children(idx).to_vec()))
1133        .unwrap_or_default();
1134    let old_children_by_dom: alloc::collections::BTreeMap<NodeId, usize> = old_tree
1135        .and_then(|t| old_tree_idx.map(|idx| {
1136            t.children(idx).iter()
1137                .filter_map(|&cidx| t.get(cidx).and_then(|n| n.dom_node_id).map(|did| (did, cidx)))
1138                .collect()
1139        }))
1140        .unwrap_or_default();
1141
1142    // Count of old layout children that correspond to a real DOM
1143    // node (exclude anonymous wrappers). This is what we compare
1144    // against the layout-relevant subset of new DOM children to
1145    // decide whether the structural shape actually changed.
1146    let old_layout_relevant_count = old_children_by_dom.len();
1147
1148    // Filter new DOM children to the subset the layout-tree builder
1149    // would actually emit. This mirrors `should_skip_for_table_structure`
1150    // and the `is_whitespace_only_inline_run` logic. Without this
1151    // filter, `children_are_different` fires on every reconcile
1152    // because the DOM has whitespace text nodes the layout tree
1153    // drops.
1154    let new_layout_relevant_count = layout_relevant_child_count(styled_dom, &new_children_dom_ids, new_dom_id);
1155
1156    let mut children_are_different = new_layout_relevant_count != old_layout_relevant_count;
1157    let mut new_child_hashes = Vec::new();
1158
1159    // +spec:display-property:42f9c0 - anonymous block boxes wrap inline runs when block container has mixed block/inline children
1160    // CSS 2.2 Section 9.2.1.1: Anonymous Block Boxes
1161    // When a block container has mixed block/inline children, we must:
1162    // 1. Wrap consecutive inline children in anonymous block boxes
1163    // 2. Leave block-level children as direct children
1164
1165    let has_block_child = new_children_dom_ids
1166        .iter()
1167        .any(|&id| is_block_level(styled_dom, id));
1168
1169    // CSS Flexbox §4 / Grid §6: every in-flow child of a flex/grid container
1170    // becomes a (blockified) flex/grid item. Anonymous-block wrapping of inline
1171    // runs is a BLOCK-container concept and must NOT apply here — otherwise an
1172    // inline-level child (e.g. an <img> with flex-grow, default display
1173    // inline-block) gets wrapped in an anonymous IFC block, so it's no longer a
1174    // direct flex item and its flex-grow is ignored (laid out 300×0). Processing
1175    // each child directly lets `blockify_node_display` (in create_node_from_dom)
1176    // see the flex/grid parent and blockify the child into a real flex item.
1177    let parent_is_flex_or_grid = matches!(
1178        get_display_type(styled_dom, new_dom_id),
1179        LayoutDisplay::Flex
1180            | LayoutDisplay::InlineFlex
1181            | LayoutDisplay::Grid
1182            | LayoutDisplay::InlineGrid
1183    );
1184
1185    if !has_block_child || parent_is_flex_or_grid {
1186        // All children are inline (block container) OR the parent is a flex/grid
1187        // container (all children are direct items) — no anonymous boxes needed.
1188        // Process each child directly.
1189        for (i, &new_child_dom_id) in new_children_dom_ids.iter().enumerate() {
1190            // DOM-ID match rather than positional — tree builder
1191            // may have dropped some DOM children (whitespace text
1192            // nodes) so positional drift mis-aligns the cache.
1193            // DOM-id match only: positional fallback would align
1194            // anonymous wrappers against real DOM nodes and trigger
1195            // spurious fingerprint mismatches (see fp_diff dump).
1196            let old_child_idx = old_children_by_dom.get(&new_child_dom_id).copied();
1197
1198            let reconciled_child_idx = reconcile_recursive(
1199                styled_dom,
1200                new_child_dom_id,
1201                old_child_idx,
1202                Some(new_node_idx),
1203                old_tree,
1204                new_tree_builder,
1205                recon,
1206                debug_messages,
1207            )?;
1208            if let Some(child_node) = new_tree_builder.get(reconciled_child_idx) {
1209                new_child_hashes.push(child_node.subtree_hash.0);
1210            }
1211
1212            if old_tree.and_then(|t| t.cold(old_child_idx?).map(|n| n.subtree_hash))
1213                != new_tree_builder
1214                    .get(reconciled_child_idx)
1215                    .map(|n| n.subtree_hash)
1216            {
1217                children_are_different = true;
1218            }
1219        }
1220    } else {
1221        // Mixed content: block and inline children
1222        // We must create anonymous block boxes around consecutive inline runs
1223
1224        if let Some(msgs) = debug_messages.as_mut() {
1225            msgs.push(LayoutDebugMessage::info(format!(
1226                "[reconcile_recursive] Mixed content in node {}: creating anonymous IFC wrappers",
1227                new_dom_id.index()
1228            )));
1229        }
1230
1231        let mut inline_run: Vec<(usize, NodeId)> = Vec::new(); // (dom_child_index, dom_id)
1232
1233        for (i, &new_child_dom_id) in new_children_dom_ids.iter().enumerate() {
1234            if is_block_level(styled_dom, new_child_dom_id) {
1235                // End current inline run if any
1236                if !inline_run.is_empty() {
1237                    // CSS 2.2 § 9.2.2.1: If the inline run consists entirely of
1238                    // whitespace-only text nodes (and white-space doesn't preserve it),
1239                    // skip creating the anonymous IFC wrapper. This prevents inter-block
1240                    // whitespace from creating empty blocks that take up vertical space.
1241                    // +spec:display-property:bef3fc - anonymous blocks of only collapsible whitespace removed from rendering tree
1242                    if is_whitespace_only_inline_run(styled_dom, &inline_run, new_dom_id) {
1243                        if let Some(msgs) = debug_messages.as_mut() {
1244                            msgs.push(LayoutDebugMessage::info(format!(
1245                                "[reconcile_recursive] Skipping whitespace-only inline run ({} nodes) between blocks in node {}",
1246                                inline_run.len(),
1247                                new_dom_id.index()
1248                            )));
1249                        }
1250                        inline_run.clear();
1251                    } else {
1252                    // Create anonymous IFC wrapper for the inline run
1253                    // This wrapper establishes an Inline Formatting Context
1254                    let anon_idx = new_tree_builder.create_anonymous_node(
1255                        new_node_idx,
1256                        AnonymousBoxType::InlineWrapper,
1257                        FormattingContext::Inline, // IFC for inline content
1258                    );
1259
1260                    if let Some(msgs) = debug_messages.as_mut() {
1261                        msgs.push(LayoutDebugMessage::info(format!(
1262                            "[reconcile_recursive] Created anonymous IFC wrapper (layout_idx={}) for {} inline children: {:?}",
1263                            anon_idx,
1264                            inline_run.len(),
1265                            inline_run.iter().map(|(_, id)| id.index()).collect::<Vec<_>>()
1266                        )));
1267                    }
1268
1269                    // Process each inline child under the anonymous wrapper
1270                    #[allow(clippy::iter_with_drain)] // accumulator Vec reused across runs; drain(..) empties it while retaining the allocation
1271                    for (pos, inline_dom_id) in inline_run.drain(..) {
1272                        // Inline children live under the anon wrapper
1273                        // in the old tree, so the parent's direct
1274                        // `old_children_by_dom` map won't hit them.
1275                        // Fall through to the global `dom_to_layout`
1276                        // map; we don't care which anon wrapper they
1277                        // were under, only that their cold data
1278                        // (fingerprint) gets matched correctly.
1279                        let old_child_idx = old_children_by_dom.get(&inline_dom_id).copied()
1280                            .or_else(|| old_tree
1281                                .and_then(|t| t.dom_to_layout.get(&inline_dom_id))
1282                                .and_then(|v| v.first().copied()));
1283                        let reconciled_child_idx = reconcile_recursive(
1284                            styled_dom,
1285                            inline_dom_id,
1286                            old_child_idx,
1287                            Some(anon_idx), // Parent is the anonymous wrapper
1288                            old_tree,
1289                            new_tree_builder,
1290                            recon,
1291                            debug_messages,
1292                        )?;
1293                        if let Some(child_node) = new_tree_builder.get(reconciled_child_idx) {
1294                            new_child_hashes.push(child_node.subtree_hash.0);
1295                        }
1296                    }
1297
1298                    // NOTE: We intentionally do NOT unconditionally
1299                    // mark the anonymous wrapper as intrinsic_dirty
1300                    // here. If any of the inline children are
1301                    // themselves dirty, their own `mark_dirty` call
1302                    // propagates upward through this wrapper, so
1303                    // wrappers whose content is unchanged keep their
1304                    // cached layout. Setting `children_are_different`
1305                    // when the wrapper is newly created (no matching
1306                    // old anon) flips the parent to layout-dirty,
1307                    // which is what triggers a fresh wrapper layout.
1308                    children_are_different = true;
1309                    } // end else (non-whitespace run)
1310                }
1311
1312                // Process block-level child directly under parent
1313                let old_child_idx = old_children_by_dom.get(&new_child_dom_id).copied()
1314                    .or_else(|| old_children_indices.get(i).copied());
1315                let reconciled_child_idx = reconcile_recursive(
1316                    styled_dom,
1317                    new_child_dom_id,
1318                    old_child_idx,
1319                    Some(new_node_idx),
1320                    old_tree,
1321                    new_tree_builder,
1322                    recon,
1323                    debug_messages,
1324                )?;
1325                if let Some(child_node) = new_tree_builder.get(reconciled_child_idx) {
1326                    new_child_hashes.push(child_node.subtree_hash.0);
1327                }
1328
1329                if old_tree.and_then(|t| t.cold(old_child_idx?).map(|n| n.subtree_hash))
1330                    != new_tree_builder
1331                        .get(reconciled_child_idx)
1332                        .map(|n| n.subtree_hash)
1333                {
1334                    children_are_different = true;
1335                }
1336            } else {
1337                // Inline-level child - add to current run
1338                inline_run.push((i, new_child_dom_id));
1339            }
1340        }
1341
1342        // Process any remaining inline run at the end
1343        if !inline_run.is_empty() {
1344            // CSS 2.2 § 9.2.2.1: Skip whitespace-only trailing inline runs
1345            if is_whitespace_only_inline_run(styled_dom, &inline_run, new_dom_id) {
1346                if let Some(msgs) = debug_messages.as_mut() {
1347                    msgs.push(LayoutDebugMessage::info(format!(
1348                        "[reconcile_recursive] Skipping trailing whitespace-only inline run ({} nodes) in node {}",
1349                        inline_run.len(),
1350                        new_dom_id.index()
1351                    )));
1352                }
1353                // Don't create a wrapper — just drop the run
1354            } else {
1355            let anon_idx = new_tree_builder.create_anonymous_node(
1356                new_node_idx,
1357                AnonymousBoxType::InlineWrapper,
1358                FormattingContext::Inline, // IFC for inline content
1359            );
1360
1361            if let Some(msgs) = debug_messages.as_mut() {
1362                msgs.push(LayoutDebugMessage::info(format!(
1363                    "[reconcile_recursive] Created trailing anonymous IFC wrapper (layout_idx={}) for {} inline children: {:?}",
1364                    anon_idx,
1365                    inline_run.len(),
1366                    inline_run.iter().map(|(_, id)| id.index()).collect::<Vec<_>>()
1367                )));
1368            }
1369
1370            #[allow(clippy::iter_with_drain)] // accumulator Vec reused across runs; drain(..) empties it while retaining the allocation
1371            for (pos, inline_dom_id) in inline_run.drain(..) {
1372                let old_child_idx = old_children_by_dom.get(&inline_dom_id).copied();
1373                let reconciled_child_idx = reconcile_recursive(
1374                    styled_dom,
1375                    inline_dom_id,
1376                    old_child_idx,
1377                    Some(anon_idx),
1378                    old_tree,
1379                    new_tree_builder,
1380                    recon,
1381                    debug_messages,
1382                )?;
1383                if let Some(child_node) = new_tree_builder.get(reconciled_child_idx) {
1384                    new_child_hashes.push(child_node.subtree_hash.0);
1385                }
1386            }
1387
1388            // See note in main mixed-content branch: rely on
1389            // children's own mark_dirty to propagate upward rather
1390            // than invalidating the whole wrapper each reconcile.
1391            children_are_different = true;
1392            } // end else (non-whitespace trailing run)
1393        }
1394    }
1395
1396    // After reconciling children, calculate this node's full subtree hash.
1397    // Use a combined hash of the fingerprint fields for the subtree hash.
1398    let node_self_hash = {
1399        use std::hash::{DefaultHasher, Hash, Hasher};
1400        let mut h = DefaultHasher::new();
1401        new_fingerprint.hash(&mut h);
1402        h.finish()
1403    };
1404    let final_subtree_hash = calculate_subtree_hash(node_self_hash, &new_child_hashes);
1405    if let Some(current_node) = new_tree_builder.get_mut(new_node_idx) {
1406        current_node.subtree_hash = final_subtree_hash;
1407    }
1408
1409    // Classify this node into the appropriate dirty set based on what changed.
1410    if dirty_flag >= DirtyFlag::Layout || children_are_different {
1411        recon.intrinsic_dirty.insert(new_node_idx);
1412        recon.layout_roots.insert(new_node_idx);
1413    } else if dirty_flag == DirtyFlag::Paint {
1414        recon.paint_dirty.insert(new_node_idx);
1415    }
1416
1417    Ok(new_node_idx)
1418}
1419
1420/// Result of `prepare_layout_context`: contains the layout constraints and
1421/// intermediate values needed for `calculate_layout_for_subtree`.
1422struct PreparedLayoutContext<'a> {
1423    constraints: LayoutConstraints<'a>,
1424    /// DOM ID for the node. None for anonymous boxes.
1425    dom_id: Option<NodeId>,
1426    writing_mode: LayoutWritingMode,
1427    final_used_size: LogicalSize,
1428    box_props: crate::solver3::geometry::BoxProps,
1429}
1430
1431/// Prepares the layout context for a single node by calculating its used size
1432/// and building the layout constraints for its children.
1433///
1434/// For anonymous boxes (no `dom_node_id`), we use default values and inherit
1435/// from the containing block.
1436fn prepare_layout_context<'a, T: ParsedFontTrait>(
1437    ctx: &LayoutContext<'a, T>,
1438    tree: &LayoutTree,
1439    node_index: usize,
1440    containing_block_size: LogicalSize,
1441) -> Result<PreparedLayoutContext<'a>> {
1442    let node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
1443    let warm = tree.warm(node_index).ok_or(LayoutError::InvalidTree)?;
1444    let dom_id = node.dom_node_id; // Can be None for anonymous boxes
1445
1446    // Phase 1: Calculate this node's provisional used size
1447
1448    // This size is based on the node's CSS properties (width, height, etc.) and
1449    // its containing block. If height is 'auto', this is a temporary value.
1450    let intrinsic = warm.intrinsic_sizes.unwrap_or_default();
1451    let final_used_size = calculate_used_size_for_node(
1452        ctx.styled_dom,
1453        dom_id, // Now Option<NodeId>
1454        &containing_block_size,
1455        intrinsic,
1456        &node.box_props.unpack(),
1457        &ctx.viewport_size,
1458    )?;
1459
1460    // Phase 2: Layout children using a formatting context
1461    // Use pre-computed styles from LayoutNodeWarm instead of repeated lookups
1462    let writing_mode = warm.computed_style.writing_mode;
1463    let text_align = warm.computed_style.text_align;
1464    let display = warm.computed_style.display;
1465    let overflow_y = warm.computed_style.overflow_y;
1466
1467    // Check if height is auto (no explicit height set)
1468    let height_is_auto = warm.computed_style.height.is_none();
1469
1470    let available_size_for_children = if height_is_auto {
1471        // Height is auto - use containing block size as available size
1472        let inner_size = node.box_props.inner_size(final_used_size, writing_mode);
1473
1474        // For inline elements (display: inline), the available width comes from
1475        // the containing block, not from the element's own intrinsic size.
1476        // CSS 2.2 § 10.3.1: Inline, non-replaced elements use containing block width.
1477        let available_width = match display {
1478            LayoutDisplay::Inline => containing_block_size.width,
1479            _ => inner_size.width,
1480        };
1481
1482        LogicalSize {
1483            width: available_width,
1484            // Use containing block height!
1485            height: containing_block_size.height,
1486        }
1487    } else {
1488        // Height is explicit - use inner size (after padding/border)
1489        node.box_props.inner_size(final_used_size, writing_mode)
1490    };
1491
1492    // NOTE: Scrollbar reservation is handled inside layout_bfc() where it subtracts
1493    // scrollbar width from children_containing_block_size. We do NOT subtract here
1494    // to avoid double-subtraction (layout_bfc already handles both the used_size
1495    // and available_size code paths).
1496
1497    let wm_ctx = crate::solver3::geometry::WritingModeContext::new(
1498        writing_mode,
1499        warm.computed_style.direction,
1500        warm.computed_style.text_orientation,
1501    );
1502    let constraints = LayoutConstraints {
1503        available_size: available_size_for_children,
1504        bfc_state: None,
1505        writing_mode,
1506        writing_mode_ctx: wm_ctx,
1507        text_align: style_text_align_to_fc(text_align),
1508        containing_block_size,
1509        available_width_type: Text3AvailableSpace::Definite(available_size_for_children.width),
1510    };
1511
1512    Ok(PreparedLayoutContext {
1513        constraints,
1514        dom_id,
1515        writing_mode,
1516        final_used_size,
1517        box_props: node.box_props.unpack(),
1518    })
1519}
1520
1521/// Core scrollbar info computation: given pre-computed content and container sizes plus
1522/// a DOM node for style look-up, determines whether scrollbars are needed.
1523///
1524/// This is the single source of truth for scrollbar detection. Both the BFC path
1525/// (`compute_scrollbar_info`) and the Taffy flex/grid path (`compute_child_layout`
1526/// in `taffy_bridge.rs`) call this function, ensuring consistent behaviour.
1527///
1528/// For paged media (PDF), scrollbars are never added since they don't exist in print.
1529pub fn compute_scrollbar_info_core<T: ParsedFontTrait>(
1530    ctx: &LayoutContext<'_, T>,
1531    dom_id: NodeId,
1532    styled_node_state: &azul_core::styled_dom::StyledNodeState,
1533    content_size: LogicalSize,
1534    container_size: LogicalSize,
1535) -> ScrollbarRequirements {
1536    // +spec:overflow:08b60d - non-interactive media: UA may show scroll indicators but we skip them for print
1537    if ctx.fragmentation_context.is_some() {
1538        return ScrollbarRequirements::default();
1539    }
1540
1541    let overflow_x = get_overflow_x(ctx.styled_dom, dom_id, styled_node_state);
1542    let overflow_y = get_overflow_y(ctx.styled_dom, dom_id, styled_node_state);
1543
1544    // Resolve the full scrollbar style **once** and reuse it
1545    // across the rest of this function + any further calls from
1546    // the same layout pass via `LayoutContext::scrollbar_style_cache`.
1547    // Previously we called `get_layout_scrollbar_width_px` (which
1548    // builds the full scrollbar_style internally, keeps only
1549    // `reserve_width_px`, then drops it) and then
1550    // `get_scrollbar_style` again — each build performs 9 cascade
1551    // walks (track/thumb/button/corner/width/color/visibility/
1552    // fade-delay/fade-duration). With the memo, subsequent calls
1553    // on the same (dom_id, state) are a HashMap hit.
1554    let scrollbar_style = crate::solver3::getters::get_scrollbar_style_cached(
1555        ctx, dom_id, styled_node_state,
1556    );
1557    let scrollbar_width_px = scrollbar_style.reserve_width_px;
1558
1559    let mut reqs = fc::check_scrollbar_necessity(
1560        content_size,
1561        container_size,
1562        to_overflow_behavior(overflow_x),
1563        to_overflow_behavior(overflow_y),
1564        scrollbar_width_px,
1565    );
1566    reqs.visual_width_px = scrollbar_style.visual_width_px;
1567
1568    // +spec:overflow:e90f12 - scrollbar-gutter reserves space independently of scrollbar presence
1569    // +spec:overflow:3c44cc - scrollbar-gutter: stable reserves gutter even when no scrollbar is shown
1570    // +spec:overflow:3a6966 - classic scrollbar gutter width == scrollbar width; overlay scrollbars have no gutter
1571    //
1572    // scrollbar-gutter only applies to scroll containers (overflow: auto or scroll).
1573    // "stable" reserves gutter on the inline-end edge even if no scrollbar is needed.
1574    // "stable both-edges" reserves gutter on both inline edges.
1575    let scrollbar_gutter = get_scrollbar_gutter_property(ctx.styled_dom, dom_id, styled_node_state)
1576        .unwrap_or(azul_css::props::layout::overflow::StyleScrollbarGutter::Auto);
1577    let ob_y = to_overflow_behavior(overflow_y);
1578    let is_scroll_container = matches!(ob_y, fc::OverflowBehavior::Scroll | fc::OverflowBehavior::Auto);
1579
1580    if is_scroll_container {
1581        use azul_css::props::layout::overflow::StyleScrollbarGutter;
1582        match scrollbar_gutter {
1583            StyleScrollbarGutter::Stable => {
1584                // Reserve gutter on inline-end even if no scrollbar is currently needed
1585                if !reqs.needs_vertical {
1586                    reqs.scrollbar_width = scrollbar_width_px;
1587                }
1588            }
1589            StyleScrollbarGutter::StableBothEdges => {
1590                // Reserve gutter on both inline edges
1591                reqs.scrollbar_width = scrollbar_width_px * 2.0;
1592            }
1593            StyleScrollbarGutter::Auto => {
1594                // Default: gutter only present when scrollbar is present (already handled)
1595            }
1596        }
1597    }
1598
1599    reqs
1600}
1601
1602/// Determines scrollbar requirements for a node based on content overflow.
1603///
1604/// Convenience wrapper around `compute_scrollbar_info_core` for the BFC layout path,
1605/// where the container size is derived from `box_props.inner_size(final_used_size, …)`.
1606fn compute_scrollbar_info<T: ParsedFontTrait>(
1607    ctx: &LayoutContext<'_, T>,
1608    dom_id: NodeId,
1609    styled_node_state: &azul_core::styled_dom::StyledNodeState,
1610    content_size: LogicalSize,
1611    box_props: &crate::solver3::geometry::BoxProps,
1612    final_used_size: LogicalSize,
1613    writing_mode: LayoutWritingMode,
1614) -> ScrollbarRequirements {
1615    let container_size = box_props.inner_size(final_used_size, writing_mode);
1616    compute_scrollbar_info_core(ctx, dom_id, styled_node_state, content_size, container_size)
1617}
1618
1619/// Checks if scrollbars changed compared to previous layout and if reflow is needed.
1620///
1621/// Detects both addition AND removal of scrollbars. Oscillation (add → remove → add)
1622/// is prevented by the outer layout loop's iteration limit (`loop_count > 10` in mod.rs),
1623/// not by suppressing removal detection here. This allows scrollbars to correctly
1624/// disappear when content shrinks or the window is resized larger.
1625fn check_scrollbar_change(
1626    tree: &LayoutTree,
1627    node_index: usize,
1628    scrollbar_info: &ScrollbarRequirements,
1629    skip_scrollbar_check: bool,
1630) -> bool {
1631    if skip_scrollbar_check {
1632        return false;
1633    }
1634
1635    let Some(warm_node) = tree.warm(node_index) else {
1636        return false;
1637    };
1638
1639    warm_node.scrollbar_info.as_ref().map_or_else(|| scrollbar_info.needs_reflow(), |old_info| {
1640            // Trigger reflow if scrollbar state changed in either direction
1641            let horizontal_changed = old_info.needs_horizontal != scrollbar_info.needs_horizontal;
1642            let vertical_changed = old_info.needs_vertical != scrollbar_info.needs_vertical;
1643            horizontal_changed || vertical_changed
1644        })
1645}
1646
1647/// Calculates the content-box position from a margin-box position.
1648///
1649/// The content-box is offset from the margin-box by border + padding.
1650/// Margin is NOT added here because `containing_block_pos` already accounts for it.
1651fn calculate_content_box_pos(
1652    containing_block_pos: LogicalPosition,
1653    box_props: &crate::solver3::geometry::BoxProps,
1654) -> LogicalPosition {
1655    LogicalPosition::new(
1656        containing_block_pos.x + box_props.border.left + box_props.padding.left,
1657        containing_block_pos.y + box_props.border.top + box_props.padding.top,
1658    )
1659}
1660
1661/// Emits debug logging for content-box calculation if debug messages are enabled.
1662fn log_content_box_calculation<T: ParsedFontTrait>(
1663    ctx: &mut LayoutContext<'_, T>,
1664    node_index: usize,
1665    current_node: &LayoutNodeHot,
1666    containing_block_pos: LogicalPosition,
1667    self_content_box_pos: LogicalPosition,
1668) {
1669    let Some(debug_msgs) = ctx.debug_messages.as_mut() else {
1670        return;
1671    };
1672
1673    let dom_name = current_node
1674        .dom_node_id
1675        .and_then(|id| {
1676            ctx.styled_dom
1677                .node_data
1678                .as_container()
1679                .internal
1680                .get(id.index())
1681        }).map_or_else(|| "Unknown".to_string(), |n| format!("{:?}", n.node_type));
1682
1683    let cbp = current_node.box_props.unpack();
1684    debug_msgs.push(LayoutDebugMessage::new(
1685        LayoutDebugMessageType::PositionCalculation,
1686        format!(
1687            "[CONTENT BOX {}] {} - margin-box pos=({:.2}, {:.2}) + border=({:.2},{:.2}) + \
1688             padding=({:.2},{:.2}) = content-box pos=({:.2}, {:.2})",
1689            node_index,
1690            dom_name,
1691            containing_block_pos.x,
1692            containing_block_pos.y,
1693            cbp.border.left,
1694            cbp.border.top,
1695            cbp.padding.left,
1696            cbp.padding.top,
1697            self_content_box_pos.x,
1698            self_content_box_pos.y
1699        ),
1700    ));
1701}
1702
1703/// Emits debug logging for child positioning if debug messages are enabled.
1704fn log_child_positioning<T: ParsedFontTrait>(
1705    ctx: &mut LayoutContext<'_, T>,
1706    child_index: usize,
1707    child_node: &LayoutNodeHot,
1708    self_content_box_pos: LogicalPosition,
1709    child_relative_pos: LogicalPosition,
1710    child_absolute_pos: LogicalPosition,
1711) {
1712    // Always print positioning info for debugging
1713    let child_dom_name = child_node
1714        .dom_node_id
1715        .and_then(|id| {
1716            ctx.styled_dom
1717                .node_data
1718                .as_container()
1719                .internal
1720                .get(id.index())
1721        }).map_or_else(|| "Unknown".to_string(), |n| format!("{:?}", n.node_type));
1722
1723    let Some(debug_msgs) = ctx.debug_messages.as_mut() else {
1724        return;
1725    };
1726
1727    debug_msgs.push(LayoutDebugMessage::new(
1728        LayoutDebugMessageType::PositionCalculation,
1729        format!(
1730            "[CHILD POS {}] {} - parent content-box=({:.2}, {:.2}) + relative=({:.2}, {:.2}) + \
1731             margin=({:.2}, {:.2}) = absolute=({:.2}, {:.2})",
1732            child_index,
1733            child_dom_name,
1734            self_content_box_pos.x,
1735            self_content_box_pos.y,
1736            child_relative_pos.x,
1737            child_relative_pos.y,
1738            child_node.box_props.unpack().margin.left,
1739            child_node.box_props.unpack().margin.top,
1740            child_absolute_pos.x,
1741            child_absolute_pos.y
1742        ),
1743    ));
1744}
1745
1746/// Processes a single in-flow child: sets position and recurses.
1747///
1748/// For Flex/Grid containers, Taffy has already laid out the children completely.
1749/// We only recurse to position their grandchildren.
1750/// For Block/Inline/Table, `layout_bfc/layout_ifc` already laid out children in Pass 1.
1751/// We only need to set absolute positions and recurse for positioning grandchildren.
1752fn process_inflow_child<T: ParsedFontTrait>(
1753    ctx: &mut LayoutContext<'_, T>,
1754    tree: &mut LayoutTree,
1755    text_cache: &TextLayoutCache,
1756    child_index: usize,
1757    child_relative_pos: LogicalPosition,
1758    self_content_box_pos: LogicalPosition,
1759    inner_size_after_scrollbars: LogicalSize,
1760    writing_mode: LayoutWritingMode,
1761    is_flex_or_grid: bool,
1762    calculated_positions: &mut super::PositionVec,
1763    reflow_needed_for_scrollbars: bool,
1764    float_cache: &HashMap<usize, fc::FloatingContext>,
1765) -> Result<()> {
1766    // Set relative position on child
1767    // child_relative_pos is [CoordinateSpace::Parent] - relative to parent's content-box
1768    let child_warm = tree.warm_mut(child_index).ok_or(LayoutError::InvalidTree)?;
1769    child_warm.relative_position = Some(child_relative_pos);
1770
1771    // Calculate absolute position
1772    // self_content_box_pos is [CoordinateSpace::Window] - absolute position of parent's content-box
1773    // child_absolute_pos becomes [CoordinateSpace::Window] - absolute window position of child
1774    let child_absolute_pos = LogicalPosition::new(
1775        self_content_box_pos.x + child_relative_pos.x,
1776        self_content_box_pos.y + child_relative_pos.y,
1777    );
1778
1779    // Debug logging
1780    {
1781        let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1782        log_child_positioning(
1783            ctx,
1784            child_index,
1785            child_node,
1786            self_content_box_pos,
1787            child_relative_pos,
1788            child_absolute_pos,
1789        );
1790    }
1791
1792    // calculated_positions stores [CoordinateSpace::Window] - absolute positions
1793    super::pos_set(calculated_positions, child_index, child_absolute_pos);
1794
1795    // Get child's properties for recursion
1796    let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
1797    let child_bp = child_node.box_props.unpack();
1798    let child_content_box_pos =
1799        calculate_content_box_pos(child_absolute_pos, &child_bp);
1800    let child_inner_size = child_bp
1801        .inner_size(child_node.used_size.unwrap_or_default(), writing_mode);
1802    let child_children: Vec<usize> = tree.children(child_index).to_vec();
1803    let child_fc = child_node.formatting_context;
1804
1805    // Recurse to position grandchildren
1806    // OPTIMIZATION: For BFC/IFC children, layout_bfc/layout_ifc already computed their layout.
1807    // We just need to set absolute positions for descendants.
1808    // Only recurse if child has children to position.
1809    if !child_children.is_empty() {
1810        if is_flex_or_grid {
1811            // For Flex/Grid: Taffy already set used_size. Only recurse for grandchildren.
1812            position_flex_child_descendants(
1813                tree,
1814                child_index,
1815                child_content_box_pos,
1816                child_inner_size,
1817                calculated_positions,
1818            )?;
1819        } else {
1820            // For Block/Inline/Table: The formatting context already laid out children.
1821            // Recursively position grandchildren using their cached layout data.
1822            position_bfc_child_descendants(
1823                tree,
1824                child_index,
1825                child_content_box_pos,
1826                calculated_positions,
1827            );
1828        }
1829    }
1830
1831    Ok(())
1832}
1833
1834/// Recursively positions descendants of a BFC/IFC child without re-computing layout.
1835/// The layout was already computed by `layout_bfc/layout_ifc`.
1836/// We only need to convert relative positions to absolute positions.
1837fn position_bfc_child_descendants(
1838    tree: &LayoutTree,
1839    node_index: usize,
1840    content_box_pos: LogicalPosition,
1841    calculated_positions: &mut super::PositionVec,
1842) {
1843    let Some(node) = tree.get(node_index) else { return };
1844
1845    for &child_index in tree.children(node_index) {
1846        let Some(child_node) = tree.get(child_index) else { continue };
1847
1848        // Use the relative_position that was set during formatting context layout
1849        let child_rel_pos = tree.warm(child_index)
1850            .and_then(|w| w.relative_position)
1851            .unwrap_or_default();
1852        let child_abs_pos = LogicalPosition::new(
1853            content_box_pos.x + child_rel_pos.x,
1854            content_box_pos.y + child_rel_pos.y,
1855        );
1856
1857        super::pos_set(calculated_positions, child_index, child_abs_pos);
1858
1859        // Calculate child's content-box position for recursion
1860        let cbp = child_node.box_props.unpack();
1861        let child_content_box_pos = LogicalPosition::new(
1862            child_abs_pos.x + cbp.border.left + cbp.padding.left,
1863            child_abs_pos.y + cbp.border.top + cbp.padding.top,
1864        );
1865        
1866        // Recurse to grandchildren
1867        position_bfc_child_descendants(tree, child_index, child_content_box_pos, calculated_positions);
1868    }
1869}
1870
1871/// Processes out-of-flow children (absolute/fixed positioned elements).
1872///
1873/// Out-of-flow elements don't appear in `layout_output.positions` but still need
1874/// a static position for when no explicit offsets are specified. This sets their
1875/// static position to the parent's content-box origin.
1876fn process_out_of_flow_children<T: ParsedFontTrait>(
1877    ctx: &mut LayoutContext<'_, T>,
1878    tree: &mut LayoutTree,
1879    text_cache: &mut TextLayoutCache,
1880    node_index: usize,
1881    self_content_box_pos: LogicalPosition,
1882    containing_block_size: LogicalSize,
1883    calculated_positions: &mut super::PositionVec,
1884    reflow_needed_for_scrollbars: &mut bool,
1885    float_cache: &mut HashMap<usize, fc::FloatingContext>,
1886) -> Result<()> {
1887    // Collect out-of-flow children (those not already positioned)
1888    let out_of_flow_children: Vec<(usize, Option<NodeId>)> = {
1889        let current_node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
1890        tree.children(node_index)
1891            .iter()
1892            .filter_map(|&child_index| {
1893                if super::pos_contains(calculated_positions, child_index) {
1894                    return None;
1895                }
1896                let child = tree.get(child_index)?;
1897                Some((child_index, child.dom_node_id))
1898            })
1899            .collect()
1900    };
1901
1902    for (child_index, child_dom_id_opt) in out_of_flow_children {
1903        let Some(child_dom_id) = child_dom_id_opt else {
1904            continue;
1905        };
1906
1907        let position_type = get_position_type(ctx.styled_dom, Some(child_dom_id));
1908        if position_type != LayoutPosition::Absolute && position_type != LayoutPosition::Fixed {
1909            continue;
1910        }
1911
1912        // Set static position to parent's content-box origin
1913        super::pos_set(calculated_positions, child_index, self_content_box_pos);
1914
1915        // Perform full layout for the absolutely positioned child so its
1916        // inline_layout_result is populated (text rendering needs this).
1917        // The containing block for abs-pos is the parent's padding box.
1918        calculate_layout_for_subtree(
1919            ctx,
1920            tree,
1921            text_cache,
1922            child_index,
1923            self_content_box_pos,
1924            containing_block_size,
1925            calculated_positions,
1926            reflow_needed_for_scrollbars,
1927            float_cache,
1928            ComputeMode::PerformLayout,
1929        )?;
1930    }
1931
1932    Ok(())
1933}
1934
1935/// Recursive, top-down pass to calculate used sizes and positions for a given subtree.
1936/// This is the single, authoritative function for in-flow layout.
1937///
1938/// Uses the per-node multi-slot cache (inspired by Taffy's 9+1 architecture) to
1939/// avoid O(n²) complexity. Each node has 9 measurement slots + 1 full layout slot.
1940///
1941/// ## Two-Mode Architecture (CSS Two-Pass Layout)
1942///
1943/// `compute_mode` determines behavior:
1944///
1945/// - **`ComputeSize`** (BFC Pass 1 — sizing):
1946///   Computes only the node's border-box size. On cache hit from measurement slots,
1947///   sets `used_size` and returns immediately — no child positioning. This is the
1948///   key to O(n) two-pass BFC: Pass 1 fills measurement caches cheaply.
1949///
1950/// - **`PerformLayout`** (BFC Pass 2 — positioning):
1951///   Computes size AND positions all children. On cache hit from layout slot,
1952///   applies cached child positions recursively. When Pass 2 provides the same
1953///   constraints as Pass 1, the "result matches request" optimization triggers
1954///   automatic cache hits.
1955///
1956/// ## Cache Hit Rates (Taffy's "result matches request" optimization)
1957///
1958/// When Pass 1 measures a node with `available_size` A and gets `result_size` R,
1959/// then Pass 2 provides R as a `known_dimension`, `get_size()` / `get_layout()`
1960/// recognize R == `cached.result_size` as a cache hit. This is the fundamental
1961/// mechanism ensuring O(n) total complexity across both passes.
1962#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
1963#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
1964/// # Errors
1965///
1966/// Returns a `LayoutError` if laying out the subtree fails.
1967pub fn calculate_layout_for_subtree<T: ParsedFontTrait>(
1968    ctx: &mut LayoutContext<'_, T>,
1969    tree: &mut LayoutTree,
1970    text_cache: &mut TextLayoutCache,
1971    node_index: usize,
1972    containing_block_pos: LogicalPosition,
1973    containing_block_size: LogicalSize,
1974    calculated_positions: &mut super::PositionVec,
1975    reflow_needed_for_scrollbars: &mut bool,
1976    float_cache: &mut HashMap<usize, fc::FloatingContext>,
1977    compute_mode: ComputeMode,
1978) -> Result<()> {
1979    // [g147b az-web-lift DIAG] per-node calculate_layout_for_subtree entry (0x60980+slot): records the
1980    // last compute_mode that reached this node (PerformLayout=2 wins, runs after ComputeSize=1). If a div
1981    // shows 0x...0002 here but its layout_formatting_context marker (0x609A0+) is UNSET → positioning
1982    // reached calculate but short-circuited (cache hit) before dispatching to the formatting context.
1983    #[cfg(feature = "web_lift")]
1984    unsafe {
1985        let m = match compute_mode { ComputeMode::PerformLayout => 0xC0DE0002u32, _ => 0xC0DE0001u32 };
1986        crate::az_mark(((0x60980 + (node_index & 7) * 4)) as u32, (m) as u32);
1987    }
1988    let _probe = match compute_mode {
1989        ComputeMode::ComputeSize => crate::probe::Probe::span("size_node"),
1990        ComputeMode::PerformLayout => crate::probe::Probe::span("pos_node"),
1991    };
1992    // HIT path; 0x60 = reached cache-miss compute.) Distinguishes stub/not-entered vs an
1993    // early Err in the cache-check vs the compute path.
1994    // === PER-NODE CACHE CHECK (Taffy-inspired 9+1 slot cache) ===
1995    //
1996    // Two-mode cache lookup (CSS two-pass architecture):
1997    //
1998    // ComputeSize (Pass 1 — sizing):
1999    //   1. Check measurement slots (get_size) → if hit, set used_size and return.
2000    //      No child positioning needed — we only need the node's border-box size.
2001    //   2. Fall back to layout slot → if hit, extract size from full layout result.
2002    //
2003    // PerformLayout (Pass 2 — positioning):
2004    //   1. Check layout slot (get_layout) → if hit, apply cached child positions.
2005    //   2. No fallback to measurement slots (we need full positions, not just size).
2006    //
2007    // This split is critical for O(n) two-pass BFC:
2008    // - Pass 1 populates measurement slots (cheap: no absolute positioning)
2009    // - Pass 2 hits layout slot or re-computes with positions
2010    if node_index < ctx.cache_map.entries.len() {
2011        match compute_mode {
2012            ComputeMode::ComputeSize => {
2013                // ComputeSize: check measurement slot first (Taffy's 9-slot scheme).
2014                // TODO(superplan): only slot 0 is ever read/written — the other 8
2015                // measurement slots are dead. To wire the full multi-slot scheme,
2016                // classify `containing_block_size` into (width_known, height_known,
2017                // width_type, height_type) and select the slot via
2018                // `NodeCache::slot_index(..)` here and at the matching `store_size`.
2019                let sizing_hit = ctx.cache_map.entries[node_index]
2020                    .get_size(0, containing_block_size)
2021                    .copied();
2022                if let Some(cached_sizing) = sizing_hit {
2023                    // SIZING CACHE HIT — set used_size and return immediately.
2024                    // No child positioning needed in ComputeSize mode.
2025                    drop(crate::probe::Probe::span("size_cache_hit_sizing"));
2026                    if let Some(node) = tree.get_mut(node_index) {
2027                        node.used_size = Some(cached_sizing.result_size);
2028                    }
2029                    if let Some(warm) = tree.warm_mut(node_index) {
2030                        warm.escaped_top_margin = cached_sizing.escaped_top_margin;
2031                        warm.escaped_bottom_margin = cached_sizing.escaped_bottom_margin;
2032                        warm.baseline = cached_sizing.baseline;
2033                    }
2034                    return Ok(());
2035                }
2036                // Fall through to layout slot check
2037                let layout_hit = ctx.cache_map.entries[node_index]
2038                    .get_layout(containing_block_size)
2039                    .cloned();
2040                if let Some(cached_layout) = layout_hit {
2041                    // Layout slot hit in ComputeSize mode — extract size only
2042                    drop(crate::probe::Probe::span("size_cache_hit_layout"));
2043                    if let Some(node) = tree.get_mut(node_index) {
2044                        node.used_size = Some(cached_layout.result_size);
2045                    }
2046                    if let Some(warm) = tree.warm_mut(node_index) {
2047                        warm.overflow_content_size = Some(cached_layout.content_size);
2048                        warm.scrollbar_info = Some(cached_layout.scrollbar_info);
2049                    }
2050                    return Ok(());
2051                }
2052                // [g147c az-web-lift DIAG] ComputeSize cache MISS for this node (0x60A60+slot): the
2053                // compute path WILL run → layout_formatting_context should fire. If a div is sized by
2054                // Pass-1 (0x60A40 set) but this miss-flag is UNSET → calculate(child,ComputeSize) hit
2055                // the cache instead (so layout_formatting_context/layout_ifc were skipped).
2056                #[cfg(feature = "web_lift")]
2057                unsafe { crate::az_mark(((0x60A60 + (node_index & 7) * 4)) as u32, (0xC0DE0001) as u32); }
2058                drop(crate::probe::Probe::span("size_cache_miss"));
2059            }
2060            ComputeMode::PerformLayout => {
2061                // PerformLayout: check layout slot (the single "full layout" slot)
2062                let layout_hit = ctx.cache_map.entries[node_index]
2063                    .get_layout(containing_block_size)
2064                    .cloned();
2065                if let Some(cached_layout) = layout_hit {
2066                    drop(crate::probe::Probe::span("pos_cache_hit"));
2067                    // LAYOUT CACHE HIT — apply cached results with child positions
2068                    if let Some(node) = tree.get_mut(node_index) {
2069                        node.used_size = Some(cached_layout.result_size);
2070                    }
2071                    if let Some(warm) = tree.warm_mut(node_index) {
2072                        warm.overflow_content_size = Some(cached_layout.content_size);
2073                        warm.scrollbar_info = Some(cached_layout.scrollbar_info);
2074                    }
2075
2076                    let box_props = tree.get(node_index)
2077                        .map(|n| n.box_props.unpack())
2078                        .unwrap_or_default();
2079                    let writing_mode = tree
2080                        .warm(node_index)
2081                        .map(|w| w.computed_style.writing_mode)
2082                        .unwrap_or_default();
2083                    let self_content_box_pos = calculate_content_box_pos(containing_block_pos, &box_props);
2084
2085                    // Apply cached child positions and recurse
2086                    let result_size = cached_layout.result_size;
2087                    for (child_index, child_relative_pos) in &cached_layout.child_positions {
2088                        let child_abs_pos = LogicalPosition::new(
2089                            self_content_box_pos.x + child_relative_pos.x,
2090                            self_content_box_pos.y + child_relative_pos.y,
2091                        );
2092                        super::pos_set(calculated_positions, *child_index, child_abs_pos);
2093
2094                        let inner = box_props.inner_size(
2095                            result_size,
2096                            writing_mode,
2097                        );
2098                        // Subtract scrollbar reservation from the available size
2099                        // passed to children. This mirrors what layout_bfc does in
2100                        // the MISS path — without it, a reflow-loop cache hit
2101                        // would hand children the full content-box width, ignoring
2102                        // any vertical/horizontal scrollbar that was detected.
2103                        let child_available_size =
2104                            cached_layout.scrollbar_info.shrink_size(inner);
2105                        calculate_layout_for_subtree(
2106                            ctx,
2107                            tree,
2108                            text_cache,
2109                            *child_index,
2110                            child_abs_pos,
2111                            child_available_size,
2112                            calculated_positions,
2113                            reflow_needed_for_scrollbars,
2114                            float_cache,
2115                            compute_mode,
2116                        )?;
2117                    }
2118
2119                    return Ok(());
2120                }
2121            }
2122        }
2123    }
2124    
2125    // === CACHE MISS — compute layout ===
2126    if compute_mode == ComputeMode::PerformLayout {
2127        drop(crate::probe::Probe::span("pos_cache_miss"));
2128    }
2129
2130    // returned Ok; 0x64 = layout_formatting_context returned Ok. Last value before the
2131    // Err pins the failing phase (fires per recursive node; bare body is shallow).
2132    // Phase 1: Prepare layout context (calculate used size, constraints)
2133    let PreparedLayoutContext {
2134        constraints,
2135        dom_id,
2136        writing_mode,
2137        mut final_used_size,
2138        box_props,
2139    } = {
2140        let _p = crate::probe::Probe::span("prepare_layout_context");
2141        prepare_layout_context(ctx, tree, node_index, containing_block_size)?
2142    };
2143
2144    // Phase 1.5: Update used_size BEFORE calling layout_formatting_context.
2145    //
2146    // When a node is cloned from the old tree (clone_node_from_old), its used_size
2147    // retains the value from the previous layout pass. If the containing block changed
2148    // (e.g. viewport resize), the stale used_size would cause layout_bfc() to compute
2149    // an incorrect children_containing_block_size. By updating used_size here, we ensure
2150    // that layout_bfc reads the freshly resolved size from prepare_layout_context.
2151    {
2152        let is_table_cell = tree.get(node_index).is_some_and(|n| {
2153            matches!(n.formatting_context, FormattingContext::TableCell)
2154        });
2155        if !is_table_cell {
2156            if let Some(node) = tree.get_mut(node_index) {
2157                node.used_size = Some(final_used_size);
2158            }
2159        }
2160    }
2161
2162    // Phase 2: Layout children using the formatting context
2163    let layout_result = {
2164        let _p = crate::probe::Probe::span("layout_formatting_context");
2165        layout_formatting_context(ctx, tree, text_cache, node_index, &constraints, float_cache)?
2166    };
2167    let content_size = layout_result.output.overflow_size;
2168
2169    // If layout_formatting_context adjusted this node's used_size (e.g.
2170    // layout_flex_grid auto-applying box-sizing:border-box on the root),
2171    // propagate that back into final_used_size so Phase 3 (scrollbars),
2172    // Phase 4 (final write), and the self_content_box_pos calculation all
2173    // see the same border-box that the children were laid out inside.
2174    if let Some(adjusted) = tree.get(node_index).and_then(|n| n.used_size) {
2175        final_used_size = adjusted;
2176    }
2177
2178    // Phase 2.5: Resolve 'auto' main-axis size based on content
2179    // For anonymous boxes, use default styled node state
2180    let styled_node_state = dom_id
2181        .and_then(|id| ctx.styled_dom.styled_nodes.as_container().get(id).cloned())
2182        .map(|n| n.styled_node_state)
2183        .unwrap_or_default();
2184
2185    let css_height: MultiValue<LayoutHeight> = match dom_id {
2186        Some(id) => get_css_height(ctx.styled_dom, id, &styled_node_state),
2187        None => MultiValue::Auto, // Anonymous boxes have auto height
2188    };
2189
2190    // +spec:overflow:44ef3b - scroll container detection: overflow scroll/auto makes box a scroll container
2191    // Check if this node is a scroll container (overflow: scroll/auto).
2192    // Scroll containers must NOT expand to fit content — their height is
2193    // determined by the containing block, and overflow is scrollable.
2194    //
2195    // Exception: if the containing block height is infinite (unconstrained),
2196    // we must still grow, since you can't scroll inside an infinitely tall box.
2197    let is_scroll_container = dom_id.is_some_and(|id| {
2198        let ov_x = get_overflow_x(ctx.styled_dom, id, &styled_node_state);
2199        let ov_y = get_overflow_y(ctx.styled_dom, id, &styled_node_state);
2200        matches!(ov_x, MultiValue::Exact(LayoutOverflow::Scroll | LayoutOverflow::Auto))
2201            || matches!(ov_y, MultiValue::Exact(LayoutOverflow::Scroll | LayoutOverflow::Auto))
2202    });
2203
2204    if should_use_content_height(&css_height) {
2205        let skip_expansion = is_scroll_container
2206            && containing_block_size.height.is_finite()
2207            && containing_block_size.height > 0.0;
2208
2209        if !skip_expansion {
2210            final_used_size = apply_content_based_height(
2211                final_used_size,
2212                content_size,
2213                tree,
2214                node_index,
2215                writing_mode,
2216            )?;
2217        }
2218    }
2219
2220    // Phase 3: Scrollbar handling
2221    // Anonymous boxes don't have scrollbars
2222    let skip_scrollbar_check = ctx.fragmentation_context.is_some();
2223    let scrollbar_info = dom_id.map_or_else(ScrollbarRequirements::default, |id| {
2224        compute_scrollbar_info(
2225            ctx,
2226            id,
2227            &styled_node_state,
2228            content_size,
2229            &box_props,
2230            final_used_size,
2231            writing_mode,
2232        )
2233    });
2234
2235    if check_scrollbar_change(tree, node_index, &scrollbar_info, skip_scrollbar_check) {
2236        *reflow_needed_for_scrollbars = true;
2237    }
2238
2239    let merged_scrollbar_info = scrollbar_info;
2240    let content_box_size = box_props.inner_size(final_used_size, writing_mode);
2241    let inner_size_after_scrollbars = merged_scrollbar_info.shrink_size(content_box_size);
2242
2243    // Phase 4: Update this node's state
2244    let self_content_box_pos = {
2245        {
2246            let current_node = tree.get_mut(node_index).ok_or(LayoutError::InvalidTree)?;
2247
2248            // Table cells get their size from the table layout algorithm, don't overwrite
2249            let is_table_cell = matches!(
2250                current_node.formatting_context,
2251                FormattingContext::TableCell
2252            );
2253            if !is_table_cell || current_node.used_size.is_none() {
2254                current_node.used_size = Some(final_used_size);
2255            }
2256        }
2257
2258        // Update warm fields
2259        if let Some(warm) = tree.warm_mut(node_index) {
2260            warm.scrollbar_info = Some(merged_scrollbar_info);
2261            // Store overflow content size for scroll frame calculation
2262            // +spec:overflow:f28d6a - hanging glyphs should be ink overflow, not scrollable overflow (not yet subtracted from content_size)
2263            warm.overflow_content_size = Some(content_size);
2264        }
2265
2266        // self_content_box_pos is [CoordinateSpace::Window] - the absolute position of this node's content-box
2267        let current_node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
2268        let current_bp = current_node.box_props.unpack();
2269        let pos = calculate_content_box_pos(containing_block_pos, &current_bp);
2270        log_content_box_calculation(ctx, node_index, current_node, containing_block_pos, pos);
2271        pos
2272    };
2273
2274    // Phase 5: Determine formatting context type
2275    let is_flex_or_grid = {
2276        let node = tree.get(node_index).ok_or(LayoutError::InvalidTree)?;
2277        matches!(
2278            node.formatting_context,
2279            FormattingContext::Flex | FormattingContext::Grid
2280        )
2281    };
2282
2283    // Phase 6: Process in-flow children
2284    // Positions in layout_result.output.positions are [CoordinateSpace::Parent] - relative to this node's content-box
2285    let positions: Vec<_> = layout_result
2286        .output
2287        .positions
2288        .iter()
2289        .map(|(&idx, &pos)| (idx, pos))
2290        .collect();
2291
2292    // Store child positions for cache
2293    let child_positions_for_cache: Vec<(usize, LogicalPosition)> = positions.clone();
2294
2295    for (child_index, child_relative_pos) in positions {
2296        process_inflow_child(
2297            ctx,
2298            tree,
2299            text_cache,
2300            child_index,
2301            child_relative_pos,
2302            self_content_box_pos,
2303            inner_size_after_scrollbars,
2304            writing_mode,
2305            is_flex_or_grid,
2306            calculated_positions,
2307            *reflow_needed_for_scrollbars,
2308            float_cache,
2309        )?;
2310    }
2311
2312    // Phase 7: Process out-of-flow children (absolute/fixed)
2313    process_out_of_flow_children(
2314        ctx,
2315        tree,
2316        text_cache,
2317        node_index,
2318        self_content_box_pos,
2319        inner_size_after_scrollbars,
2320        calculated_positions,
2321        reflow_needed_for_scrollbars,
2322        float_cache,
2323    )?;
2324
2325    // === STORE RESULT IN PER-NODE CACHE (Taffy-inspired 9+1 slot cache) ===
2326    // Store both the full layout entry and a sizing measurement entry.
2327    // This enables O(n) two-pass BFC: Pass 1 populates cache, Pass 2 reads it.
2328    if node_index < ctx.cache_map.entries.len() {
2329        let warm_ref = tree.warm(node_index);
2330        let baseline = warm_ref.and_then(|n| n.baseline);
2331        let escaped_top = warm_ref.and_then(|n| n.escaped_top_margin);
2332        let escaped_bottom = warm_ref.and_then(|n| n.escaped_bottom_margin);
2333
2334        // Store in the layout slot (PerformLayout result)
2335        ctx.cache_map.get_mut(node_index).store_layout(LayoutCacheEntry {
2336            available_size: containing_block_size,
2337            result_size: final_used_size,
2338            content_size,
2339            child_positions: child_positions_for_cache,
2340            escaped_top_margin: escaped_top,
2341            escaped_bottom_margin: escaped_bottom,
2342            scrollbar_info: merged_scrollbar_info,
2343        });
2344
2345        // Also store in a measurement slot (slot 0: both dimensions known).
2346        // This enables the "result matches request" optimization (Taffy pattern):
2347        // when Pass 2 provides the same size as Pass 1 measured, it's a cache hit.
2348        // TODO(superplan): see the matching note at the `get_size(0, ..)` site —
2349        // slots 1-8 are unused; wire `NodeCache::slot_index(..)` to populate them.
2350        ctx.cache_map.get_mut(node_index).store_size(0, SizingCacheEntry {
2351            available_size: containing_block_size,
2352            result_size: final_used_size,
2353            baseline,
2354            escaped_top_margin: escaped_top,
2355            escaped_bottom_margin: escaped_bottom,
2356        });
2357    }
2358
2359    Ok(())
2360}
2361
2362/// Recursively set static positions for out-of-flow descendants without doing layout
2363/// Recursively positions descendants of Flex/Grid children.
2364///
2365/// When a Flex container lays out its children via Taffy, the children have their
2366/// `used_size` and `relative_position` set, but their GRANDCHILDREN don't have positions
2367/// in `calculated_positions` yet. This function traverses down the tree and positions
2368/// all descendants properly.
2369fn position_flex_child_descendants(
2370    tree: &mut LayoutTree,
2371    node_index: usize,
2372    content_box_pos: LogicalPosition,
2373    available_size: LogicalSize,
2374    calculated_positions: &mut super::PositionVec,
2375) -> Result<()> {
2376    let children: Vec<usize> = tree.children(node_index).to_vec();
2377
2378    for &child_index in &children {
2379        let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;
2380        let child_rel_pos = tree.warm(child_index)
2381            .and_then(|w| w.relative_position)
2382            .unwrap_or_default();
2383        let child_abs_pos = LogicalPosition::new(
2384            content_box_pos.x + child_rel_pos.x,
2385            content_box_pos.y + child_rel_pos.y,
2386        );
2387
2388        // Insert position
2389        super::pos_set(calculated_positions, child_index, child_abs_pos);
2390
2391        // Get child's content box for recursion
2392        let cbp = child_node.box_props.unpack();
2393        let child_writing_mode = tree
2394            .warm(child_index)
2395            .map(|w| w.computed_style.writing_mode)
2396            .unwrap_or_default();
2397        let child_content_box = LogicalPosition::new(
2398            child_abs_pos.x
2399                + cbp.border.left
2400                + cbp.padding.left,
2401            child_abs_pos.y
2402                + cbp.border.top
2403                + cbp.padding.top,
2404        );
2405        let child_inner_size = cbp.inner_size(
2406            child_node.used_size.unwrap_or_default(),
2407            child_writing_mode,
2408        );
2409
2410        // Recurse
2411        position_flex_child_descendants(
2412            tree,
2413            child_index,
2414            child_content_box,
2415            child_inner_size,
2416            calculated_positions,
2417        )?;
2418    }
2419
2420    Ok(())
2421}
2422
2423/// Checks if the given CSS height value should use content-based sizing
2424#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2425fn should_use_content_height(css_height: &MultiValue<LayoutHeight>) -> bool {
2426    match css_height {
2427        MultiValue::Auto | MultiValue::Initial | MultiValue::Inherit => {
2428            // Auto/Initial/Inherit height should use content-based sizing
2429            true
2430        }
2431        MultiValue::Exact(height) => match height {
2432            LayoutHeight::Auto => {
2433                // Auto height should use content-based sizing
2434                true
2435            }
2436            LayoutHeight::Px(px) => {
2437                // Check if it's zero or if it has explicit value
2438                // If it's a percentage or em, it's not auto
2439                use azul_css::props::basic::{pixel::PixelValue, SizeMetric};
2440                px == &PixelValue::zero()
2441                    || (px.metric != SizeMetric::Px
2442                        && px.metric != SizeMetric::Percent
2443                        && px.metric != SizeMetric::Em
2444                        && px.metric != SizeMetric::Rem)
2445            }
2446            LayoutHeight::MinContent | LayoutHeight::MaxContent | LayoutHeight::FitContent(_) => {
2447                // These are content-based, so they should use the content size
2448                true
2449            }
2450            LayoutHeight::Calc(_) => {
2451                // Calc expressions are not auto, they compute to a specific value
2452                false
2453            }
2454        },
2455    }
2456}
2457
2458/// Applies content-based height sizing to a node
2459///
2460/// **Note**: This function respects min-height/max-height constraints from Phase 1.
2461///
2462/// According to CSS 2.2 § 10.7, when height is 'auto', the final height must be
2463/// `max(min_height`, `min(content_height`, `max_height`)).
2464///
2465/// The `used_size` parameter already contains the size constrained by
2466/// min-height/max-height from the initial sizing pass. We must take the
2467/// maximum of this constrained size and the new content-based size to ensure
2468/// min-height is not lost.
2469fn apply_content_based_height(
2470    mut used_size: LogicalSize,
2471    content_size: LogicalSize,
2472    tree: &LayoutTree,
2473    node_index: usize,
2474    writing_mode: LayoutWritingMode,
2475) -> Result<LogicalSize> {
2476    let node_props = tree.get(node_index).ok_or(LayoutError::InvalidTree)?.box_props.unpack();
2477    let main_axis_padding_border =
2478        node_props.padding.main_sum(writing_mode) + node_props.border.main_sum(writing_mode);
2479
2480    // CRITICAL: 'old_main_size' holds the size constrained by min-height/max-height from Phase 1
2481    let old_main_size = used_size.main(writing_mode);
2482    let new_main_size = content_size.main(writing_mode) + main_axis_padding_border;
2483
2484    // Final size = max(min_height_constrained_size, content_size)
2485    // This ensures that min-height is respected even when content is smaller
2486    let final_main_size = old_main_size.max(new_main_size);
2487
2488    used_size = used_size.with_main(writing_mode, final_main_size);
2489
2490    Ok(used_size)
2491}
2492
2493// hash_styled_node_data() removed — replaced by NodeDataFingerprint::compute()
2494
2495fn calculate_subtree_hash(node_self_hash: u64, child_hashes: &[u64]) -> SubtreeHash {
2496    let mut hasher = DefaultHasher::new();
2497    node_self_hash.hash(&mut hasher);
2498    child_hashes.hash(&mut hasher);
2499    SubtreeHash(hasher.finish())
2500}
2501
2502/// Computes CSS counter values for all nodes in the layout tree.
2503///
2504/// This function traverses the tree in document order and processes counter-reset
2505/// and counter-increment properties. The computed values are stored in cache.counters.
2506///
2507/// CSS counters work with a stack-based scoping model:
2508/// - `counter-reset` creates a new scope and sets the counter to a value
2509/// - `counter-increment` increments the counter in the current scope
2510/// - When leaving a subtree, counter scopes are popped
2511#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
2512pub fn compute_counters(
2513    styled_dom: &StyledDom,
2514    tree: &LayoutTree,
2515    counters: &mut HashMap<(usize, String), i32>,
2516) {
2517    // Track counter stacks: counter_name -> Vec<value>
2518    // Each entry in the Vec represents a nested scope
2519    let mut counter_stacks: HashMap<String, Vec<i32>> = HashMap::new();
2520
2521    // Stack to track which counters were reset at each tree level
2522    // When we pop back up the tree, we need to pop these counter scopes
2523    let mut scope_stack: Vec<Vec<String>> = Vec::new();
2524
2525    compute_counters_recursive(
2526        styled_dom,
2527        tree,
2528        tree.root,
2529        counters,
2530        &mut counter_stacks,
2531        &mut scope_stack,
2532    );
2533}
2534
2535#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2536fn compute_counters_recursive(
2537    styled_dom: &StyledDom,
2538    tree: &LayoutTree,
2539    node_idx: usize,
2540    counters: &mut HashMap<(usize, String), i32>,
2541    counter_stacks: &mut HashMap<String, Vec<i32>>,
2542    scope_stack: &mut Vec<Vec<String>>,
2543) {
2544    let Some(node) = tree.get(node_idx) else {
2545        return;
2546    };
2547
2548    // Skip pseudo-elements (::marker, ::before, ::after) for counter processing
2549    // Pseudo-elements inherit counter values from their parent element
2550    // but don't participate in counter-reset or counter-increment themselves
2551    if tree.warm(node_idx).and_then(|w| w.pseudo_element.as_ref()).is_some() {
2552        // Store the parent's counter values for this pseudo-element
2553        // so it can be looked up during marker text generation
2554        if let Some(parent_idx) = node.parent {
2555            // Copy all counter values from parent to this pseudo-element
2556            let parent_counters: Vec<_> = counters
2557                .iter()
2558                .filter(|((idx, _), _)| *idx == parent_idx)
2559                .map(|((_, name), &value)| (name.clone(), value))
2560                .collect();
2561
2562            for (counter_name, value) in parent_counters {
2563                counters.insert((node_idx, counter_name), value);
2564            }
2565        }
2566
2567        // Don't recurse to children of pseudo-elements
2568        // (pseudo-elements shouldn't have children in normal circumstances)
2569        return;
2570    }
2571
2572    // Only process real DOM nodes, not anonymous boxes
2573    let Some(dom_id) = node.dom_node_id else {
2574        // For anonymous boxes, just recurse to children
2575        for &child_idx in tree.children(node_idx) {
2576            compute_counters_recursive(
2577                styled_dom,
2578                tree,
2579                child_idx,
2580                counters,
2581                counter_stacks,
2582                scope_stack,
2583            );
2584        }
2585        return;
2586    };
2587
2588    let node_data = &styled_dom.node_data.as_container()[dom_id];
2589    let node_state = &styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
2590    let cache = &styled_dom.css_property_cache.ptr;
2591
2592    // Track which counters we reset at this level (for cleanup later)
2593    let mut reset_counters_at_this_level = Vec::new();
2594
2595    // CSS Lists §3: display: list-item automatically increments the "list-item" counter
2596    // Check if this is a list-item
2597    let display = {
2598        use crate::solver3::getters::get_display_property;
2599        get_display_property(styled_dom, Some(dom_id)).exact()
2600    };
2601    let is_list_item = matches!(display, Some(LayoutDisplay::ListItem));
2602
2603    // FAST PATH: almost no nodes declare counter-reset/counter-increment.
2604    // Single-bit check in compact cache lets us skip two cascade walks per node.
2605    let has_counter_css = node_state.is_normal()
2606        && cache.compact_cache.as_ref().is_none_or(|cc| cc.has_counter(dom_id.index()));
2607
2608    // Process counter-reset (now properly typed)
2609    let counter_reset = if has_counter_css {
2610        cache
2611            .get_counter_reset(node_data, &dom_id, node_state)
2612            .and_then(|v| v.get_property())
2613    } else {
2614        None
2615    };
2616
2617    if let Some(counter_reset) = counter_reset {
2618        let counter_name_str = counter_reset.counter_name.as_str();
2619        if counter_name_str != "none" {
2620            let counter_name = counter_name_str.to_string();
2621            let reset_value = counter_reset.value;
2622
2623            // Reset the counter by pushing a new scope
2624            counter_stacks
2625                .entry(counter_name.clone())
2626                .or_default()
2627                .push(reset_value);
2628            reset_counters_at_this_level.push(counter_name);
2629        }
2630    }
2631
2632    // Process counter-increment (now properly typed)
2633    let counter_inc = if has_counter_css {
2634        cache
2635            .get_counter_increment(node_data, &dom_id, node_state)
2636            .and_then(|v| v.get_property())
2637    } else {
2638        None
2639    };
2640
2641    if let Some(counter_inc) = counter_inc {
2642        let counter_name_str = counter_inc.counter_name.as_str();
2643        if counter_name_str != "none" {
2644            let counter_name = counter_name_str.to_string();
2645            let inc_value = counter_inc.value;
2646
2647            // Increment the counter in the current scope
2648            let stack = counter_stacks.entry(counter_name).or_default();
2649            if stack.is_empty() {
2650                // Auto-initialize if counter doesn't exist
2651                stack.push(inc_value);
2652            } else if let Some(current) = stack.last_mut() {
2653                *current += inc_value;
2654            }
2655        }
2656    }
2657
2658    // CSS Lists §3: display: list-item automatically increments "list-item" counter
2659    if is_list_item {
2660        let counter_name = "list-item".to_string();
2661        let stack = counter_stacks.entry(counter_name).or_default();
2662        if stack.is_empty() {
2663            // Auto-initialize if counter doesn't exist
2664            stack.push(1);
2665        } else if let Some(current) = stack.last_mut() {
2666            *current += 1;
2667        }
2668    }
2669
2670    // Store the current counter values for this node
2671    for (counter_name, stack) in counter_stacks.iter() {
2672        if let Some(&value) = stack.last() {
2673            counters.insert((node_idx, counter_name.clone()), value);
2674        }
2675    }
2676
2677    // Push scope tracking for cleanup
2678    scope_stack.push(reset_counters_at_this_level.clone());
2679
2680    // Recurse to children
2681    for &child_idx in tree.children(node_idx) {
2682        compute_counters_recursive(
2683            styled_dom,
2684            tree,
2685            child_idx,
2686            counters,
2687            counter_stacks,
2688            scope_stack,
2689        );
2690    }
2691
2692    // Pop counter scopes that were created at this level
2693    if let Some(reset_counters) = scope_stack.pop() {
2694        for counter_name in reset_counters {
2695            if let Some(stack) = counter_stacks.get_mut(&counter_name) {
2696                stack.pop();
2697            }
2698        }
2699    }
2700}
2701
2702#[cfg(test)]
2703#[allow(clippy::float_cmp)]
2704mod autotest_generated {
2705    use azul_core::dom::{Dom, IdOrClass};
2706    use azul_css::props::{
2707        basic::{pixel::PixelValue, SizeMetric},
2708        layout::dimensions::CalcAstItemVec,
2709    };
2710
2711    use super::*;
2712    use crate::solver3::{
2713        display_list::DisplayList,
2714        geometry::{EdgeSizes, MarginAuto, PackedBoxProps, ResolvedBoxProps},
2715        layout_tree::{LayoutNodeCold, LayoutNodeWarm},
2716        pos_get, PositionVec, POSITION_UNSET,
2717    };
2718
2719    // ------------------------------------------------------------------
2720    // Fixtures
2721    // ------------------------------------------------------------------
2722
2723    fn size(w: f32, h: f32) -> LogicalSize {
2724        LogicalSize::new(w, h)
2725    }
2726
2727    fn pos(x: f32, y: f32) -> LogicalPosition {
2728        LogicalPosition::new(x, y)
2729    }
2730
2731    fn sizing_entry(available: LogicalSize, result: LogicalSize) -> SizingCacheEntry {
2732        SizingCacheEntry {
2733            available_size: available,
2734            result_size: result,
2735            baseline: None,
2736            escaped_top_margin: None,
2737            escaped_bottom_margin: None,
2738        }
2739    }
2740
2741    fn layout_entry(available: LogicalSize, result: LogicalSize) -> LayoutCacheEntry {
2742        LayoutCacheEntry {
2743            available_size: available,
2744            result_size: result,
2745            content_size: result,
2746            child_positions: Vec::new(),
2747            escaped_top_margin: None,
2748            escaped_bottom_margin: None,
2749            scrollbar_info: ScrollbarRequirements::default(),
2750        }
2751    }
2752
2753    fn edges(top: f32, right: f32, bottom: f32, left: f32) -> EdgeSizes {
2754        EdgeSizes {
2755            top,
2756            right,
2757            bottom,
2758            left,
2759        }
2760    }
2761
2762    fn box_props(margin: EdgeSizes, border: EdgeSizes, padding: EdgeSizes) -> ResolvedBoxProps {
2763        ResolvedBoxProps {
2764            margin,
2765            padding,
2766            border,
2767            margin_auto: MarginAuto::default(),
2768        }
2769    }
2770
2771    fn zero_box_props() -> ResolvedBoxProps {
2772        box_props(
2773            edges(0.0, 0.0, 0.0, 0.0),
2774            edges(0.0, 0.0, 0.0, 0.0),
2775            edges(0.0, 0.0, 0.0, 0.0),
2776        )
2777    }
2778
2779    fn hot(
2780        parent: Option<usize>,
2781        dom_node_id: Option<NodeId>,
2782        used_size: Option<LogicalSize>,
2783        bp: &ResolvedBoxProps,
2784    ) -> LayoutNodeHot {
2785        LayoutNodeHot {
2786            box_props: PackedBoxProps::pack(bp),
2787            dom_node_id,
2788            used_size,
2789            formatting_context: FormattingContext::Block {
2790                establishes_new_context: false,
2791            },
2792            parent,
2793        }
2794    }
2795
2796    /// Plain hot node with no box props and no DOM id.
2797    fn plain(parent: Option<usize>) -> LayoutNodeHot {
2798        hot(parent, None, Some(size(0.0, 0.0)), &zero_box_props())
2799    }
2800
2801    /// Builds a `LayoutTree` from hot nodes + per-node child lists.
2802    /// `child_lists[i]` are the children of node `i`.
2803    fn build_tree(
2804        nodes: Vec<LayoutNodeHot>,
2805        warm: Vec<LayoutNodeWarm>,
2806        child_lists: &[Vec<usize>],
2807    ) -> LayoutTree {
2808        let n = nodes.len();
2809        let mut children_arena: Vec<usize> = Vec::new();
2810        let mut children_offsets: Vec<(u32, u32)> = Vec::with_capacity(n);
2811        for cl in child_lists {
2812            let start = u32::try_from(children_arena.len()).unwrap();
2813            children_arena.extend_from_slice(cl);
2814            children_offsets.push((start, u32::try_from(cl.len()).unwrap()));
2815        }
2816        while children_offsets.len() < n {
2817            children_offsets.push((0, 0));
2818        }
2819        LayoutTree {
2820            nodes,
2821            warm,
2822            cold: vec![LayoutNodeCold::default(); n],
2823            root: 0,
2824            dom_to_layout: BTreeMap::new(),
2825            children_arena,
2826            children_offsets,
2827            subtree_needs_intrinsic: Vec::new(),
2828        }
2829    }
2830
2831    fn warm_default(n: usize) -> Vec<LayoutNodeWarm> {
2832        vec![LayoutNodeWarm::default(); n]
2833    }
2834
2835    fn div_class(class: &str) -> Dom {
2836        Dom::create_div().with_ids_and_classes(vec![IdOrClass::Class(class.into())].into())
2837    }
2838
2839    fn styled(dom: Dom, css_str: &str) -> StyledDom {
2840        let mut dom = dom;
2841        let (css, _warnings) = azul_css::parser2::new_from_str(css_str);
2842        StyledDom::create(&mut dom, css)
2843    }
2844
2845    /// `body(0) > .p(1) > [ text " \n\t"(2), text "hi"(3), div(4), text NBSP(5) ]`
2846    ///
2847    /// DOM ids follow the depth-first pre-order numbering of `CompactDom`.
2848    fn whitespace_dom(css_str: &str) -> StyledDom {
2849        styled(
2850            Dom::create_body().with_child(
2851                div_class("p")
2852                    .with_child(Dom::create_text(" \n\t"))
2853                    .with_child(Dom::create_text("hi"))
2854                    .with_child(Dom::create_div())
2855                    .with_child(Dom::create_text("\u{00A0}")),
2856            ),
2857            css_str,
2858        )
2859    }
2860
2861    // ==================================================================
2862    // NodeCache — slot cache (numeric / round-trip)
2863    // ==================================================================
2864
2865    #[test]
2866    fn nodecache_default_is_empty_and_never_hits() {
2867        let c = NodeCache::default();
2868        assert!(c.is_empty);
2869        assert!(c.layout_entry.is_none());
2870        assert!(c.measure_entries.iter().all(Option::is_none));
2871        for slot in 0..9 {
2872            assert!(c.get_size(slot, size(0.0, 0.0)).is_none());
2873            assert!(c.get_size(slot, size(100.0, 100.0)).is_none());
2874        }
2875        assert!(c.get_layout(size(0.0, 0.0)).is_none());
2876    }
2877
2878    #[test]
2879    fn nodecache_store_size_then_exact_lookup_round_trips() {
2880        let mut c = NodeCache::default();
2881        c.store_size(0, sizing_entry(size(200.0, 100.0), size(50.0, 40.0)));
2882        assert!(!c.is_empty);
2883
2884        let hit = c.get_size(0, size(200.0, 100.0)).expect("exact hit");
2885        assert_eq!(hit.available_size, size(200.0, 100.0));
2886        assert_eq!(hit.result_size, size(50.0, 40.0));
2887    }
2888
2889    #[test]
2890    fn nodecache_get_size_result_matches_request() {
2891        // Taffy's key optimization: Pass 2 hands back the size Pass 1 produced.
2892        let mut c = NodeCache::default();
2893        c.store_size(0, sizing_entry(size(200.0, 100.0), size(50.0, 40.0)));
2894
2895        let hit = c.get_size(0, size(50.0, 40.0)).expect("result-matches-request hit");
2896        assert_eq!(hit.result_size, size(50.0, 40.0));
2897        // A size matching neither the request nor the result must miss.
2898        assert!(c.get_size(0, size(51.0, 41.0)).is_none());
2899    }
2900
2901    #[test]
2902    fn nodecache_get_size_epsilon_boundary() {
2903        let mut c = NodeCache::default();
2904        c.store_size(0, sizing_entry(size(100.0, 100.0), size(10.0, 10.0)));
2905
2906        // Sub-epsilon drift on either axis is still a hit (CACHE_SIZE_EPSILON = 0.1).
2907        assert!(c.get_size(0, size(100.05, 99.95)).is_some());
2908        // A drift clearly past the epsilon is a miss on both the request and the
2909        // result comparison. (The exact `== EPSILON` boundary is deliberately not
2910        // asserted: `100.1f32 - 100.0f32` rounds to 0.09999847, just under it.)
2911        assert!(c.get_size(0, size(100.2, 100.0)).is_none());
2912        assert!(c.get_size(0, size(100.0, 99.5)).is_none());
2913    }
2914
2915    #[test]
2916    fn nodecache_get_size_nan_request_misses_instead_of_panicking() {
2917        let mut c = NodeCache::default();
2918        c.store_size(0, sizing_entry(size(100.0, 100.0), size(10.0, 10.0)));
2919
2920        // NaN - x = NaN, and every NaN comparison is false → miss, not a hit.
2921        assert!(c.get_size(0, size(f32::NAN, 100.0)).is_none());
2922        assert!(c.get_size(0, size(100.0, f32::NAN)).is_none());
2923        assert!(c.get_size(0, size(f32::NAN, f32::NAN)).is_none());
2924    }
2925
2926    #[test]
2927    fn nodecache_nan_and_infinite_entries_are_unreachable() {
2928        // An entry stored with a non-finite available/result size can never be
2929        // hit again (inf - inf = NaN, NaN - NaN = NaN) — the node simply gets
2930        // re-measured. That is safe, but it means such slots are dead weight.
2931        let mut c = NodeCache::default();
2932        c.store_size(
2933            0,
2934            sizing_entry(size(f32::INFINITY, f32::INFINITY), size(f32::INFINITY, f32::INFINITY)),
2935        );
2936        assert!(c.get_size(0, size(f32::INFINITY, f32::INFINITY)).is_none());
2937
2938        c.store_size(1, sizing_entry(size(f32::NAN, f32::NAN), size(f32::NAN, f32::NAN)));
2939        assert!(c.get_size(1, size(f32::NAN, f32::NAN)).is_none());
2940        // ...but the cache still reports itself as populated.
2941        assert!(!c.is_empty);
2942    }
2943
2944    #[test]
2945    fn nodecache_handles_zero_and_negative_sizes() {
2946        let mut c = NodeCache::default();
2947        c.store_size(0, sizing_entry(size(0.0, 0.0), size(0.0, 0.0)));
2948        assert!(c.get_size(0, size(0.0, 0.0)).is_some());
2949        assert!(c.get_size(0, size(-0.0, -0.0)).is_some());
2950
2951        c.store_size(1, sizing_entry(size(-100.0, -50.0), size(-1.0, -1.0)));
2952        let hit = c.get_size(1, size(-100.0, -50.0)).expect("negative sizes are deterministic");
2953        assert_eq!(hit.result_size, size(-1.0, -1.0));
2954    }
2955
2956    #[test]
2957    fn nodecache_extreme_finite_sizes_do_not_panic() {
2958        let mut c = NodeCache::default();
2959        c.store_size(0, sizing_entry(size(f32::MAX, f32::MIN), size(f32::MAX, f32::MIN)));
2960        // MAX - MAX == 0 → exact hit; no overflow panic on the subtraction.
2961        assert!(c.get_size(0, size(f32::MAX, f32::MIN)).is_some());
2962        // MIN - MAX overflows to -inf, abs() = inf, inf < 0.1 is false → miss.
2963        assert!(c.get_size(0, size(f32::MIN, f32::MAX)).is_none());
2964    }
2965
2966    #[test]
2967    fn nodecache_slots_are_independent() {
2968        let mut c = NodeCache::default();
2969        c.store_size(0, sizing_entry(size(1.0, 1.0), size(1.0, 1.0)));
2970        c.store_size(8, sizing_entry(size(2.0, 2.0), size(2.0, 2.0)));
2971
2972        assert!(c.get_size(0, size(1.0, 1.0)).is_some());
2973        assert!(c.get_size(8, size(2.0, 2.0)).is_some());
2974        // No cross-talk between slots.
2975        assert!(c.get_size(0, size(2.0, 2.0)).is_none());
2976        assert!(c.get_size(8, size(1.0, 1.0)).is_none());
2977        assert!(c.get_size(4, size(1.0, 1.0)).is_none());
2978    }
2979
2980    #[test]
2981    #[should_panic(expected = "index out of bounds")]
2982    fn nodecache_get_size_slot_out_of_range_panics() {
2983        // There are exactly 9 measurement slots; slot 9 is a caller bug and is
2984        // reported as an index panic rather than silently returning None.
2985        let c = NodeCache::default();
2986        let _ = c.get_size(9, size(0.0, 0.0));
2987    }
2988
2989    #[test]
2990    #[should_panic(expected = "index out of bounds")]
2991    fn nodecache_store_size_slot_out_of_range_panics() {
2992        let mut c = NodeCache::default();
2993        c.store_size(9, sizing_entry(size(1.0, 1.0), size(1.0, 1.0)));
2994    }
2995
2996    #[test]
2997    fn nodecache_layout_slot_round_trips_and_matches_result() {
2998        let mut c = NodeCache::default();
2999        c.store_layout(layout_entry(size(800.0, 600.0), size(800.0, 123.0)));
3000        assert!(!c.is_empty);
3001
3002        assert!(c.get_layout(size(800.0, 600.0)).is_some());
3003        // "Result matches request" applies to the layout slot too.
3004        let hit = c.get_layout(size(800.0, 123.0)).expect("result-matches-request");
3005        assert_eq!(hit.result_size, size(800.0, 123.0));
3006        assert!(c.get_layout(size(400.0, 300.0)).is_none());
3007        assert!(c.get_layout(size(f32::NAN, f32::NAN)).is_none());
3008    }
3009
3010    #[test]
3011    fn nodecache_clear_wipes_every_slot() {
3012        let mut c = NodeCache::default();
3013        for slot in 0..9 {
3014            c.store_size(slot, sizing_entry(size(10.0, 10.0), size(10.0, 10.0)));
3015        }
3016        c.store_layout(layout_entry(size(10.0, 10.0), size(10.0, 10.0)));
3017        assert!(!c.is_empty);
3018
3019        c.clear();
3020
3021        assert!(c.is_empty);
3022        assert!(c.layout_entry.is_none());
3023        assert!(c.measure_entries.iter().all(Option::is_none));
3024        assert!(c.get_size(0, size(10.0, 10.0)).is_none());
3025        assert!(c.get_layout(size(10.0, 10.0)).is_none());
3026
3027        // Clearing twice is harmless.
3028        c.clear();
3029        assert!(c.is_empty);
3030    }
3031
3032    #[test]
3033    fn slot_index_always_lands_in_range_and_partitions_the_unknown_case() {
3034        use AvailableWidthType::{Definite, MaxContent, MinContent};
3035        let types = [Definite, MinContent, MaxContent];
3036
3037        for &wt in &types {
3038            for &ht in &types {
3039                for wk in [true, false] {
3040                    for hk in [true, false] {
3041                        let slot = NodeCache::slot_index(wk, hk, wt, ht);
3042                        assert!(slot < 9, "slot {slot} out of the 9-slot range");
3043                    }
3044                }
3045            }
3046        }
3047
3048        // Both known → always slot 0, regardless of the constraint types.
3049        for &wt in &types {
3050            for &ht in &types {
3051                assert_eq!(NodeCache::slot_index(true, true, wt, ht), 0);
3052            }
3053        }
3054
3055        // Neither known → the 4 MinContent combos partition slots 5..=8.
3056        let mut neither: Vec<usize> = Vec::new();
3057        for &wt in &[Definite, MinContent] {
3058            for &ht in &[Definite, MinContent] {
3059                neither.push(NodeCache::slot_index(false, false, wt, ht));
3060            }
3061        }
3062        neither.sort_unstable();
3063        assert_eq!(neither, vec![5, 6, 7, 8]);
3064    }
3065
3066    #[test]
3067    fn slot_index_collapses_definite_and_maxcontent_onto_one_slot() {
3068        use AvailableWidthType::{Definite, MaxContent, MinContent};
3069        // Documented: "MaxContent/Definite vs MinContent" share a slot.
3070        assert_eq!(
3071            NodeCache::slot_index(true, false, Definite, Definite),
3072            NodeCache::slot_index(true, false, MaxContent, Definite)
3073        );
3074        assert_ne!(
3075            NodeCache::slot_index(true, false, Definite, Definite),
3076            NodeCache::slot_index(true, false, MinContent, Definite)
3077        );
3078    }
3079
3080    #[test]
3081    fn slot_index_keys_the_single_unknown_axis_off_the_known_axis_type() {
3082        use AvailableWidthType::{Definite, MinContent};
3083        // BEHAVIOUR PIN (deviates from Taffy): when only the width is known, the
3084        // slot is chosen from `width_type` — the type of the *known* axis — even
3085        // though the doc comment says it keys off "the unknown dimension(s)".
3086        // Taffy keys slot 1/2 off the height (the unknown axis) here. Same for
3087        // the mirrored (false, true) case, which keys off `height_type`.
3088        // Consequence: the height's MinContent-ness cannot select slot 2 at all,
3089        // so a MinContent and a Definite height measurement would collide in a
3090        // single slot once slots 1-8 are wired up (they are unused today).
3091        assert_eq!(NodeCache::slot_index(true, false, Definite, MinContent), 1);
3092        assert_eq!(NodeCache::slot_index(true, false, MinContent, Definite), 2);
3093        assert_eq!(NodeCache::slot_index(false, true, MinContent, Definite), 3);
3094        assert_eq!(NodeCache::slot_index(false, true, Definite, MinContent), 4);
3095    }
3096
3097    // ==================================================================
3098    // LayoutCacheMap
3099    // ==================================================================
3100
3101    #[test]
3102    fn cachemap_resize_to_tree_grows_shrinks_and_zeroes() {
3103        let mut m = LayoutCacheMap::default();
3104        assert!(m.entries.is_empty());
3105
3106        m.resize_to_tree(0);
3107        assert!(m.entries.is_empty());
3108
3109        m.resize_to_tree(3);
3110        assert_eq!(m.entries.len(), 3);
3111        assert!(m.entries.iter().all(|e| e.is_empty));
3112
3113        // Populated entries survive a grow; new entries are dirty.
3114        m.get_mut(1).store_size(0, sizing_entry(size(5.0, 5.0), size(5.0, 5.0)));
3115        m.resize_to_tree(5);
3116        assert_eq!(m.entries.len(), 5);
3117        assert!(!m.get(1).is_empty);
3118        assert!(m.get(4).is_empty);
3119
3120        // Shrink drops the tail.
3121        m.resize_to_tree(1);
3122        assert_eq!(m.entries.len(), 1);
3123        m.resize_to_tree(0);
3124        assert!(m.entries.is_empty());
3125    }
3126
3127    #[test]
3128    #[should_panic(expected = "index out of bounds")]
3129    fn cachemap_get_out_of_range_panics() {
3130        let m = LayoutCacheMap::default();
3131        let _ = m.get(0);
3132    }
3133
3134    #[test]
3135    fn cachemap_mark_dirty_out_of_range_index_is_a_noop() {
3136        let mut m = LayoutCacheMap::default();
3137        m.resize_to_tree(2);
3138        m.get_mut(0).store_size(0, sizing_entry(size(1.0, 1.0), size(1.0, 1.0)));
3139
3140        m.mark_dirty(99, &[]);
3141        m.mark_dirty(usize::MAX, &[]);
3142
3143        // Guard clause hit: nothing was touched.
3144        assert!(!m.get(0).is_empty);
3145        assert_eq!(m.entries.len(), 2);
3146    }
3147
3148    #[test]
3149    fn cachemap_mark_dirty_propagates_up_the_ancestor_chain() {
3150        // 0 <- 1 <- 2
3151        let tree = vec![plain(None), plain(Some(0)), plain(Some(1))];
3152        let mut m = LayoutCacheMap::default();
3153        m.resize_to_tree(3);
3154        for i in 0..3 {
3155            m.get_mut(i).store_size(0, sizing_entry(size(1.0, 1.0), size(1.0, 1.0)));
3156        }
3157
3158        m.mark_dirty(2, &tree);
3159
3160        assert!(m.get(2).is_empty);
3161        assert!(m.get(1).is_empty);
3162        assert!(m.get(0).is_empty);
3163    }
3164
3165    #[test]
3166    fn cachemap_mark_dirty_stops_at_the_first_dirty_ancestor() {
3167        // 0 (clean) <- 1 (already dirty) <- 2 (clean)
3168        let tree = vec![plain(None), plain(Some(0)), plain(Some(1))];
3169        let mut m = LayoutCacheMap::default();
3170        m.resize_to_tree(3);
3171        m.get_mut(0).store_size(0, sizing_entry(size(1.0, 1.0), size(1.0, 1.0)));
3172        m.get_mut(2).store_size(0, sizing_entry(size(1.0, 1.0), size(1.0, 1.0)));
3173
3174        m.mark_dirty(2, &tree);
3175
3176        assert!(m.get(2).is_empty);
3177        assert!(m.get(1).is_empty);
3178        // Early stop: the grandparent keeps its cached entry.
3179        assert!(!m.get(0).is_empty);
3180    }
3181
3182    #[test]
3183    fn cachemap_mark_dirty_on_an_already_dirty_node_leaves_ancestors_alone() {
3184        let tree = vec![plain(None), plain(Some(0))];
3185        let mut m = LayoutCacheMap::default();
3186        m.resize_to_tree(2);
3187        m.get_mut(0).store_size(0, sizing_entry(size(1.0, 1.0), size(1.0, 1.0)));
3188        // entry 1 is fresh → already dirty
3189
3190        m.mark_dirty(1, &tree);
3191
3192        assert!(m.get(1).is_empty);
3193        assert!(!m.get(0).is_empty);
3194    }
3195
3196    #[test]
3197    fn cachemap_mark_dirty_terminates_on_cyclic_parent_links() {
3198        // A malformed tree (0 <-> 1, and 2 as its own parent) must not spin
3199        // forever: the `is_empty` early-stop breaks every cycle after one lap.
3200        let tree = vec![plain(Some(1)), plain(Some(0)), plain(Some(2))];
3201        let mut m = LayoutCacheMap::default();
3202        m.resize_to_tree(3);
3203        for i in 0..3 {
3204            m.get_mut(i).store_size(0, sizing_entry(size(1.0, 1.0), size(1.0, 1.0)));
3205        }
3206
3207        m.mark_dirty(0, &tree);
3208        assert!(m.get(0).is_empty);
3209        assert!(m.get(1).is_empty);
3210
3211        m.mark_dirty(2, &tree);
3212        assert!(m.get(2).is_empty);
3213    }
3214
3215    #[test]
3216    fn cachemap_mark_dirty_survives_a_tree_that_disagrees_with_the_cache() {
3217        let mut m = LayoutCacheMap::default();
3218        m.resize_to_tree(3);
3219        for i in 0..3 {
3220            m.get_mut(i).store_size(0, sizing_entry(size(1.0, 1.0), size(1.0, 1.0)));
3221        }
3222
3223        // Tree shorter than the cache: parent lookup returns None → stop.
3224        let short_tree = vec![plain(None)];
3225        m.mark_dirty(2, &short_tree);
3226        assert!(m.get(2).is_empty);
3227        assert!(!m.get(0).is_empty);
3228
3229        // Parent index past the end of the cache: break, don't index-panic.
3230        let dangling = vec![plain(Some(usize::MAX)), plain(None), plain(None)];
3231        m.mark_dirty(0, &dangling);
3232        assert!(m.get(0).is_empty);
3233    }
3234
3235    // ==================================================================
3236    // Solver3CacheMemoryReport / LayoutCache (getters)
3237    // ==================================================================
3238
3239    #[test]
3240    fn memory_report_total_bytes_sums_every_field_exactly_once() {
3241        assert_eq!(Solver3CacheMemoryReport::default().total_bytes(), 0);
3242
3243        // Distinct powers of two: a missing or double-counted field shows up as
3244        // a wrong total rather than an accidental coincidence.
3245        let r = Solver3CacheMemoryReport {
3246            tree_bytes: 1,
3247            tree_report: None,
3248            calculated_positions_bytes: 2,
3249            previous_positions_bytes: 4,
3250            scroll_ids_bytes: 8,
3251            scroll_id_to_node_id_bytes: 16,
3252            counters_bytes: 32,
3253            float_cache_bytes: 64,
3254            cache_map_bytes: 128,
3255            cached_display_list_bytes: 256,
3256        };
3257        assert_eq!(r.total_bytes(), 511);
3258    }
3259
3260    #[test]
3261    fn memory_report_of_a_default_cache_is_all_zero() {
3262        let cache = LayoutCache::default();
3263        let r = cache.memory_report();
3264        assert_eq!(r.total_bytes(), 0);
3265        assert_eq!(r.tree_bytes, 0);
3266        assert!(r.tree_report.is_none());
3267        assert_eq!(r.cache_map_bytes, 0);
3268        assert_eq!(r.cached_display_list_bytes, 0);
3269    }
3270
3271    #[test]
3272    fn memory_report_accounts_for_populated_state() {
3273        let mut cache = LayoutCache::default();
3274        cache.cache_map.resize_to_tree(4);
3275        cache.calculated_positions = vec![pos(1.0, 2.0), pos(3.0, 4.0), pos(5.0, 6.0)];
3276        cache.previous_positions = vec![pos(0.0, 0.0)];
3277        cache.counters.insert((0, "list-item".to_string()), 7);
3278        cache.float_cache.insert(0, fc::FloatingContext::default());
3279        cache.scroll_ids.insert(0, 42);
3280        cache.scroll_id_to_node_id.insert(42, NodeId::ZERO);
3281        cache.cached_display_list = Some((
3282            SubtreeHash(1),
3283            LogicalRect::new(pos(0.0, 0.0), size(10.0, 10.0)),
3284            DisplayList::default(),
3285        ));
3286
3287        let r = cache.memory_report();
3288
3289        assert!(r.cache_map_bytes >= 4 * size_of::<NodeCache>());
3290        assert_eq!(r.calculated_positions_bytes, 3 * size_of::<LogicalPosition>());
3291        assert_eq!(r.previous_positions_bytes, size_of::<LogicalPosition>());
3292        assert!(r.counters_bytes >= "list-item".len());
3293        assert_eq!(r.float_cache_bytes, 256);
3294        assert_eq!(r.cached_display_list_bytes, 2048);
3295        assert_eq!(
3296            r.total_bytes(),
3297            r.tree_bytes
3298                + r.calculated_positions_bytes
3299                + r.previous_positions_bytes
3300                + r.scroll_ids_bytes
3301                + r.scroll_id_to_node_id_bytes
3302                + r.counters_bytes
3303                + r.float_cache_bytes
3304                + r.cache_map_bytes
3305                + r.cached_display_list_bytes
3306        );
3307    }
3308
3309    #[test]
3310    fn reset_incremental_drops_reuse_state_keeps_the_rest_and_is_idempotent() {
3311        let mut cache = LayoutCache {
3312            tree: Some(build_tree(vec![plain(None)], warm_default(1), &[vec![]])),
3313            ..Default::default()
3314        };
3315        cache.cache_map.resize_to_tree(2);
3316        cache.cached_display_list = Some((
3317            SubtreeHash(9),
3318            LogicalRect::new(pos(0.0, 0.0), size(1.0, 1.0)),
3319            DisplayList::default(),
3320        ));
3321        cache.prev_dom_ptr = 0xDEAD_BEEF;
3322        cache.counters.insert((0, "c".to_string()), 1);
3323        cache.float_cache.insert(0, fc::FloatingContext::default());
3324        // Not incremental-reuse state — must survive.
3325        cache.calculated_positions = vec![pos(1.0, 2.0)];
3326        cache.scroll_ids.insert(0, 5);
3327        cache.viewport = Some(LogicalRect::new(pos(0.0, 0.0), size(800.0, 600.0)));
3328
3329        cache.reset_incremental();
3330
3331        assert!(cache.tree.is_none());
3332        assert!(cache.cache_map.entries.is_empty());
3333        assert!(cache.cached_display_list.is_none());
3334        assert_eq!(cache.prev_dom_ptr, 0);
3335        assert!(cache.counters.is_empty());
3336        assert!(cache.float_cache.is_empty());
3337        assert_eq!(cache.calculated_positions.len(), 1);
3338        assert_eq!(cache.scroll_ids.len(), 1);
3339        assert!(cache.viewport.is_some());
3340
3341        // Idempotent: a second reset on the already-cold cache is a no-op.
3342        cache.reset_incremental();
3343        assert!(cache.tree.is_none());
3344        // Only the two retained fields (1 position + 1 scroll id) still cost bytes.
3345        assert_eq!(
3346            cache.memory_report().total_bytes(),
3347            size_of::<LogicalPosition>() + size_of::<usize>() + size_of::<u64>()
3348        );
3349    }
3350
3351    // ==================================================================
3352    // ReconciliationResult (predicates)
3353    // ==================================================================
3354
3355    #[test]
3356    fn reconciliation_result_default_is_clean() {
3357        let r = ReconciliationResult::default();
3358        assert!(r.is_clean());
3359        assert!(!r.needs_layout());
3360        assert!(!r.needs_paint_only());
3361    }
3362
3363    #[test]
3364    fn reconciliation_result_predicates_hold_over_every_combination() {
3365        for intrinsic in [false, true] {
3366            for roots in [false, true] {
3367                for paint in [false, true] {
3368                    let mut r = ReconciliationResult::default();
3369                    if intrinsic {
3370                        r.intrinsic_dirty.insert(0);
3371                    }
3372                    if roots {
3373                        r.layout_roots.insert(usize::MAX);
3374                    }
3375                    if paint {
3376                        r.paint_dirty.insert(7);
3377                    }
3378
3379                    let expect_layout = intrinsic || roots;
3380                    assert_eq!(r.needs_layout(), expect_layout);
3381                    assert_eq!(r.is_clean(), !intrinsic && !roots && !paint);
3382                    assert_eq!(r.needs_paint_only(), !expect_layout && paint);
3383                    // Invariants: clean ⇒ no work; layout and paint-only are exclusive.
3384                    assert!(!(r.is_clean() && (r.needs_layout() || r.needs_paint_only())));
3385                    assert!(!(r.needs_layout() && r.needs_paint_only()));
3386                }
3387            }
3388        }
3389    }
3390
3391    // ==================================================================
3392    // to_overflow_behavior / style_text_align_to_fc (mapping tables)
3393    // ==================================================================
3394
3395    #[test]
3396    fn to_overflow_behavior_maps_every_layout_overflow_variant() {
3397        assert_eq!(
3398            to_overflow_behavior(MultiValue::Exact(LayoutOverflow::Visible)),
3399            fc::OverflowBehavior::Visible
3400        );
3401        assert_eq!(
3402            to_overflow_behavior(MultiValue::Exact(LayoutOverflow::Hidden)),
3403            fc::OverflowBehavior::Hidden
3404        );
3405        assert_eq!(
3406            to_overflow_behavior(MultiValue::Exact(LayoutOverflow::Scroll)),
3407            fc::OverflowBehavior::Scroll
3408        );
3409        assert_eq!(
3410            to_overflow_behavior(MultiValue::Exact(LayoutOverflow::Auto)),
3411            fc::OverflowBehavior::Auto
3412        );
3413        // BEHAVIOUR PIN: `overflow: clip` is folded into Hidden, so the distinct
3414        // `OverflowBehavior::Clip` variant is never produced here. Clip differs
3415        // from hidden in CSS Overflow 3 (no scroll container, no scrollport), so
3416        // a `clip` box is currently treated as a (non-scrollable) hidden box.
3417        assert_eq!(
3418            to_overflow_behavior(MultiValue::Exact(LayoutOverflow::Clip)),
3419            fc::OverflowBehavior::Hidden
3420        );
3421    }
3422
3423    #[test]
3424    fn to_overflow_behavior_falls_back_to_the_initial_value() {
3425        // CSS Overflow 3: initial value is `visible`. Auto/Initial/Inherit here
3426        // are the *CSS-wide keyword* arms of MultiValue, not `overflow: auto`.
3427        for mv in [MultiValue::Auto, MultiValue::Initial, MultiValue::Inherit] {
3428            assert_eq!(to_overflow_behavior(mv), fc::OverflowBehavior::Visible);
3429        }
3430    }
3431
3432    #[test]
3433    fn overflow_auto_keyword_is_a_typed_value_not_a_css_wide_keyword() {
3434        // Regression guard: if `overflow: auto` were parsed as the generic
3435        // CSS-wide `auto` keyword it would arrive as MultiValue::Auto and
3436        // to_overflow_behavior would silently downgrade it to Visible — i.e. no
3437        // scrollbars at all. It must arrive as Exact(LayoutOverflow::Auto).
3438        let sd = styled(
3439            Dom::create_body().with_child(div_class("s")),
3440            ".s { overflow-x: auto; overflow-y: scroll; }",
3441        );
3442        let id = NodeId::new(1);
3443        let state = sd.styled_nodes.as_container()[id].styled_node_state;
3444
3445        assert_eq!(
3446            to_overflow_behavior(get_overflow_x(&sd, id, &state)),
3447            fc::OverflowBehavior::Auto
3448        );
3449        assert_eq!(
3450            to_overflow_behavior(get_overflow_y(&sd, id, &state)),
3451            fc::OverflowBehavior::Scroll
3452        );
3453    }
3454
3455    #[test]
3456    fn style_text_align_to_fc_maps_every_variant() {
3457        // fc::TextAlign has no PartialEq, so match on the variant.
3458        assert!(matches!(
3459            style_text_align_to_fc(StyleTextAlign::Start),
3460            fc::TextAlign::Start
3461        ));
3462        assert!(matches!(
3463            style_text_align_to_fc(StyleTextAlign::Left),
3464            fc::TextAlign::Start
3465        ));
3466        assert!(matches!(
3467            style_text_align_to_fc(StyleTextAlign::End),
3468            fc::TextAlign::End
3469        ));
3470        assert!(matches!(
3471            style_text_align_to_fc(StyleTextAlign::Right),
3472            fc::TextAlign::End
3473        ));
3474        assert!(matches!(
3475            style_text_align_to_fc(StyleTextAlign::Center),
3476            fc::TextAlign::Center
3477        ));
3478        assert!(matches!(
3479            style_text_align_to_fc(StyleTextAlign::Justify),
3480            fc::TextAlign::Justify
3481        ));
3482    }
3483
3484    // ==================================================================
3485    // should_use_content_height (predicate)
3486    // ==================================================================
3487
3488    #[test]
3489    fn should_use_content_height_for_css_wide_keywords_and_auto() {
3490        assert!(should_use_content_height(&MultiValue::Auto));
3491        assert!(should_use_content_height(&MultiValue::Initial));
3492        assert!(should_use_content_height(&MultiValue::Inherit));
3493        assert!(should_use_content_height(&MultiValue::Exact(LayoutHeight::Auto)));
3494    }
3495
3496    #[test]
3497    fn should_use_content_height_is_false_for_definite_lengths() {
3498        for pv in [
3499            PixelValue::px(100.0),
3500            PixelValue::px(-10.0),
3501            PixelValue::percent(50.0),
3502            PixelValue::percent(0.0),
3503            PixelValue::em(2.0),
3504            PixelValue::rem(2.0),
3505        ] {
3506            assert!(
3507                !should_use_content_height(&MultiValue::Exact(LayoutHeight::Px(pv))),
3508                "expected a definite height for {pv:?}"
3509            );
3510        }
3511    }
3512
3513    #[test]
3514    fn should_use_content_height_treats_zero_px_as_content_based() {
3515        // BEHAVIOUR PIN: `height: 0px` is indistinguishable from `auto` here, so
3516        // an explicitly zero-height box falls back to content sizing.
3517        assert!(should_use_content_height(&MultiValue::Exact(LayoutHeight::Px(
3518            PixelValue::zero()
3519        ))));
3520        assert!(should_use_content_height(&MultiValue::Exact(LayoutHeight::Px(
3521            PixelValue::px(0.0)
3522        ))));
3523        // ...but `height: 0%` is NOT (its metric is Percent, not Px).
3524        assert!(!should_use_content_height(&MultiValue::Exact(LayoutHeight::Px(
3525            PixelValue::percent(0.0)
3526        ))));
3527    }
3528
3529    #[test]
3530    fn should_use_content_height_treats_non_px_metrics_as_content_based() {
3531        // BEHAVIOUR PIN (suspected bug): only Px/Percent/Em/Rem are recognised as
3532        // definite. Every other metric — pt, vh, vw, cm, in, mm — is reported as
3533        // content-based, so `height: 100vh` behaves like a *minimum* height (see
3534        // apply_content_based_height, which takes max(used, content)) instead of
3535        // a definite one.
3536        for metric in [
3537            SizeMetric::Pt,
3538            SizeMetric::Vh,
3539            SizeMetric::Vw,
3540            SizeMetric::Cm,
3541            SizeMetric::Mm,
3542            SizeMetric::In,
3543        ] {
3544            let pv = PixelValue::from_metric(metric, 100.0);
3545            assert!(
3546                should_use_content_height(&MultiValue::Exact(LayoutHeight::Px(pv))),
3547                "{metric:?} is currently treated as content-based"
3548            );
3549        }
3550    }
3551
3552    #[test]
3553    fn should_use_content_height_for_intrinsic_keywords_but_not_calc() {
3554        assert!(should_use_content_height(&MultiValue::Exact(LayoutHeight::MinContent)));
3555        assert!(should_use_content_height(&MultiValue::Exact(LayoutHeight::MaxContent)));
3556        assert!(should_use_content_height(&MultiValue::Exact(LayoutHeight::FitContent(
3557            PixelValue::px(10.0)
3558        ))));
3559        // calc() resolves to a definite value.
3560        assert!(!should_use_content_height(&MultiValue::Exact(LayoutHeight::Calc(
3561            CalcAstItemVec::from_vec(Vec::new())
3562        ))));
3563    }
3564
3565    // ==================================================================
3566    // apply_content_based_height (numeric)
3567    // ==================================================================
3568
3569    fn one_node_tree_with(bp: &ResolvedBoxProps) -> LayoutTree {
3570        build_tree(
3571            vec![hot(None, None, Some(size(100.0, 100.0)), bp)],
3572            warm_default(1),
3573            &[vec![]],
3574        )
3575    }
3576
3577    #[test]
3578    fn apply_content_based_height_keeps_the_larger_of_min_height_and_content() {
3579        let bp = box_props(
3580            edges(0.0, 0.0, 0.0, 0.0),
3581            edges(0.0, 0.0, 0.0, 0.0),
3582            edges(10.0, 0.0, 10.0, 0.0), // padding: 20px on the block axis
3583        );
3584        let tree = one_node_tree_with(&bp);
3585        let wm = LayoutWritingMode::HorizontalTb;
3586
3587        // Content (50 + 20 padding = 70) is smaller than the min-height-constrained
3588        // 100 → the Phase-1 size wins (CSS 2.2 § 10.7).
3589        let out = apply_content_based_height(size(100.0, 100.0), size(0.0, 50.0), &tree, 0, wm)
3590            .expect("valid node");
3591        assert_eq!(out, size(100.0, 100.0));
3592
3593        // Content wins when it is taller.
3594        let out = apply_content_based_height(size(100.0, 40.0), size(0.0, 50.0), &tree, 0, wm)
3595            .expect("valid node");
3596        assert_eq!(out, size(100.0, 70.0));
3597    }
3598
3599    #[test]
3600    fn apply_content_based_height_at_zero_and_in_vertical_writing_modes() {
3601        let tree = one_node_tree_with(&zero_box_props());
3602
3603        let out = apply_content_based_height(
3604            size(0.0, 0.0),
3605            size(0.0, 0.0),
3606            &tree,
3607            0,
3608            LayoutWritingMode::HorizontalTb,
3609        )
3610        .expect("valid node");
3611        assert_eq!(out, size(0.0, 0.0));
3612
3613        // Vertical writing mode: the main axis is the *width*.
3614        let out = apply_content_based_height(
3615            size(10.0, 80.0),
3616            size(50.0, 0.0),
3617            &tree,
3618            0,
3619            LayoutWritingMode::VerticalRl,
3620        )
3621        .expect("valid node");
3622        assert_eq!(out, size(50.0, 80.0));
3623    }
3624
3625    #[test]
3626    fn apply_content_based_height_does_not_propagate_nan() {
3627        let tree = one_node_tree_with(&zero_box_props());
3628        let wm = LayoutWritingMode::HorizontalTb;
3629
3630        // f32::max ignores a NaN operand, so a NaN content size cannot poison the
3631        // used size — the Phase-1 height survives.
3632        let out = apply_content_based_height(size(100.0, 40.0), size(0.0, f32::NAN), &tree, 0, wm)
3633            .expect("valid node");
3634        assert!(!out.height.is_nan());
3635        assert_eq!(out.height, 40.0);
3636
3637        // ...and symmetrically, a NaN used size is replaced by the content size.
3638        let out =
3639            apply_content_based_height(size(100.0, f32::NAN), size(0.0, 50.0), &tree, 0, wm)
3640                .expect("valid node");
3641        assert!(!out.height.is_nan());
3642        assert_eq!(out.height, 50.0);
3643    }
3644
3645    #[test]
3646    fn apply_content_based_height_saturates_at_infinity_and_rejects_bad_indices() {
3647        let tree = one_node_tree_with(&zero_box_props());
3648        let wm = LayoutWritingMode::HorizontalTb;
3649
3650        let out =
3651            apply_content_based_height(size(10.0, 10.0), size(0.0, f32::INFINITY), &tree, 0, wm)
3652                .expect("valid node");
3653        assert!(out.height.is_infinite() && out.height.is_sign_positive());
3654
3655        // A huge finite content size stays finite (no overflow panic).
3656        let out = apply_content_based_height(size(10.0, 10.0), size(0.0, f32::MAX), &tree, 0, wm)
3657            .expect("valid node");
3658        assert_eq!(out.height, f32::MAX);
3659
3660        // Out-of-range node → Err, not a panic.
3661        assert!(apply_content_based_height(size(1.0, 1.0), size(1.0, 1.0), &tree, 1, wm).is_err());
3662        assert!(
3663            apply_content_based_height(size(1.0, 1.0), size(1.0, 1.0), &tree, usize::MAX, wm)
3664                .is_err()
3665        );
3666    }
3667
3668    // ==================================================================
3669    // calculate_subtree_hash (numeric / round-trip-ish)
3670    // ==================================================================
3671
3672    #[test]
3673    fn subtree_hash_is_deterministic() {
3674        let a = calculate_subtree_hash(42, &[1, 2, 3]);
3675        let b = calculate_subtree_hash(42, &[1, 2, 3]);
3676        assert_eq!(a, b);
3677        assert_eq!(a, calculate_subtree_hash(42, &[1, 2, 3]));
3678    }
3679
3680    #[test]
3681    fn subtree_hash_depends_on_child_order_and_arity() {
3682        let base = calculate_subtree_hash(1, &[10, 20]);
3683        assert_ne!(base, calculate_subtree_hash(1, &[20, 10]), "order must matter");
3684        assert_ne!(base, calculate_subtree_hash(1, &[10, 20, 30]), "arity must matter");
3685        assert_ne!(base, calculate_subtree_hash(1, &[10]), "a dropped child must matter");
3686        assert_ne!(base, calculate_subtree_hash(2, &[10, 20]), "self hash must matter");
3687    }
3688
3689    #[test]
3690    fn subtree_hash_distinguishes_no_children_from_a_zero_child() {
3691        // Length is folded in (slice Hash writes the length), so an empty child
3692        // list is not confusable with a single 0-hash child.
3693        assert_ne!(
3694            calculate_subtree_hash(0, &[]),
3695            calculate_subtree_hash(0, &[0])
3696        );
3697        assert_ne!(
3698            calculate_subtree_hash(0, &[0]),
3699            calculate_subtree_hash(0, &[0, 0])
3700        );
3701    }
3702
3703    #[test]
3704    fn subtree_hash_does_not_swap_self_hash_and_child_hash() {
3705        assert_ne!(
3706            calculate_subtree_hash(7, &[9]),
3707            calculate_subtree_hash(9, &[7])
3708        );
3709    }
3710
3711    #[test]
3712    fn subtree_hash_handles_extremes_and_large_child_lists() {
3713        let extremes = calculate_subtree_hash(u64::MAX, &[u64::MAX, 0, u64::MAX]);
3714        assert_eq!(
3715            extremes,
3716            calculate_subtree_hash(u64::MAX, &[u64::MAX, 0, u64::MAX])
3717        );
3718        assert_ne!(extremes, calculate_subtree_hash(0, &[0, 0, 0]));
3719
3720        // 10k children: no overflow, no panic, still deterministic.
3721        let many: Vec<u64> = (0..10_000).collect();
3722        assert_eq!(
3723            calculate_subtree_hash(1, &many),
3724            calculate_subtree_hash(1, &many)
3725        );
3726    }
3727
3728    // ==================================================================
3729    // calculate_content_box_pos (numeric)
3730    // ==================================================================
3731
3732    #[test]
3733    fn content_box_pos_adds_border_and_padding_but_not_margin() {
3734        let bp = box_props(
3735            edges(999.0, 999.0, 999.0, 999.0), // margin must be ignored
3736            edges(2.0, 0.0, 0.0, 3.0),         // border top/left
3737            edges(5.0, 0.0, 0.0, 7.0),         // padding top/left
3738        );
3739        let out = calculate_content_box_pos(pos(10.0, 20.0), &bp);
3740        assert_eq!(out, pos(10.0 + 3.0 + 7.0, 20.0 + 2.0 + 5.0));
3741
3742        // Zero box props → identity.
3743        assert_eq!(
3744            calculate_content_box_pos(pos(4.0, 5.0), &zero_box_props()),
3745            pos(4.0, 5.0)
3746        );
3747    }
3748
3749    #[test]
3750    fn content_box_pos_with_negative_edges_shifts_backwards() {
3751        let bp = box_props(
3752            edges(0.0, 0.0, 0.0, 0.0),
3753            edges(-1.0, 0.0, 0.0, -2.0),
3754            edges(-3.0, 0.0, 0.0, -4.0),
3755        );
3756        assert_eq!(
3757            calculate_content_box_pos(pos(0.0, 0.0), &bp),
3758            pos(-6.0, -4.0)
3759        );
3760    }
3761
3762    #[test]
3763    fn content_box_pos_with_non_finite_edges_is_deterministic() {
3764        let nan_bp = box_props(
3765            edges(0.0, 0.0, 0.0, 0.0),
3766            edges(f32::NAN, 0.0, 0.0, f32::NAN),
3767            edges(0.0, 0.0, 0.0, 0.0),
3768        );
3769        let out = calculate_content_box_pos(pos(1.0, 1.0), &nan_bp);
3770        assert!(out.x.is_nan() && out.y.is_nan(), "NaN edges propagate, no panic");
3771
3772        // +inf border with a -inf containing block → NaN, still no panic.
3773        let inf_bp = box_props(
3774            edges(0.0, 0.0, 0.0, 0.0),
3775            edges(f32::INFINITY, 0.0, 0.0, f32::INFINITY),
3776            edges(0.0, 0.0, 0.0, 0.0),
3777        );
3778        let out = calculate_content_box_pos(pos(f32::NEG_INFINITY, f32::NEG_INFINITY), &inf_bp);
3779        assert!(out.x.is_nan() && out.y.is_nan());
3780
3781        // Saturation rather than wrap-around at the top of the f32 range.
3782        let max_bp = box_props(
3783            edges(0.0, 0.0, 0.0, 0.0),
3784            edges(f32::MAX, 0.0, 0.0, f32::MAX),
3785            edges(f32::MAX, 0.0, 0.0, f32::MAX),
3786        );
3787        let out = calculate_content_box_pos(pos(f32::MAX, f32::MAX), &max_bp);
3788        assert!(out.x.is_infinite() && out.x.is_sign_positive());
3789        assert!(out.y.is_infinite() && out.y.is_sign_positive());
3790    }
3791
3792    // ==================================================================
3793    // check_scrollbar_change (numeric / predicate)
3794    // ==================================================================
3795
3796    fn scrollbars(h: bool, v: bool, w: f32) -> ScrollbarRequirements {
3797        ScrollbarRequirements {
3798            needs_horizontal: h,
3799            needs_vertical: v,
3800            scrollbar_width: w,
3801            scrollbar_height: w,
3802            visual_width_px: w,
3803        }
3804    }
3805
3806    fn tree_with_scrollbar_info(info: Option<ScrollbarRequirements>) -> LayoutTree {
3807        let mut warm = warm_default(1);
3808        warm[0].scrollbar_info = info;
3809        build_tree(vec![plain(None)], warm, &[vec![]])
3810    }
3811
3812    #[test]
3813    fn check_scrollbar_change_skip_flag_short_circuits_everything() {
3814        let tree = tree_with_scrollbar_info(Some(scrollbars(false, false, 0.0)));
3815        // Even a full add of both scrollbars is suppressed by the skip flag.
3816        assert!(!check_scrollbar_change(&tree, 0, &scrollbars(true, true, 16.0), true));
3817    }
3818
3819    #[test]
3820    fn check_scrollbar_change_out_of_range_node_is_false() {
3821        let tree = tree_with_scrollbar_info(None);
3822        assert!(!check_scrollbar_change(&tree, 1, &scrollbars(true, true, 16.0), false));
3823        assert!(!check_scrollbar_change(&tree, usize::MAX, &scrollbars(true, true, 16.0), false));
3824    }
3825
3826    #[test]
3827    fn check_scrollbar_change_without_previous_info_uses_needs_reflow() {
3828        let tree = tree_with_scrollbar_info(None);
3829        // No reserved space → nothing to reflow for.
3830        assert!(!check_scrollbar_change(&tree, 0, &scrollbars(true, true, 0.0), false));
3831        // Reserved space → reflow.
3832        assert!(check_scrollbar_change(&tree, 0, &scrollbars(false, false, 16.0), false));
3833        // NaN reserved width: `NaN > 0.0` is false → deterministic `false`, no panic.
3834        assert!(!check_scrollbar_change(&tree, 0, &scrollbars(true, true, f32::NAN), false));
3835    }
3836
3837    #[test]
3838    fn check_scrollbar_change_detects_both_addition_and_removal() {
3839        let had_vertical = tree_with_scrollbar_info(Some(scrollbars(false, true, 16.0)));
3840        // Removal (vertical true → false) must be detected, not suppressed.
3841        assert!(check_scrollbar_change(&had_vertical, 0, &scrollbars(false, false, 0.0), false));
3842        // Addition of the horizontal bar.
3843        assert!(check_scrollbar_change(&had_vertical, 0, &scrollbars(true, true, 16.0), false));
3844        // No change at all.
3845        assert!(!check_scrollbar_change(&had_vertical, 0, &scrollbars(false, true, 16.0), false));
3846    }
3847
3848    #[test]
3849    fn check_scrollbar_change_ignores_width_only_changes() {
3850        // BEHAVIOUR PIN: only the needs_horizontal/needs_vertical booleans are
3851        // compared. A scrollbar that keeps existing but changes reserved width
3852        // (e.g. a restyle from a classic to a thin scrollbar) does not trigger a
3853        // reflow here.
3854        let tree = tree_with_scrollbar_info(Some(scrollbars(false, true, 16.0)));
3855        assert!(!check_scrollbar_change(&tree, 0, &scrollbars(false, true, 4.0), false));
3856    }
3857
3858    // ==================================================================
3859    // shift_subtree_position (numeric)
3860    // ==================================================================
3861
3862    /// 0 -> [1, 2]; 1 -> [3]
3863    fn shift_fixture() -> (LayoutTree, PositionVec) {
3864        let tree = build_tree(
3865            vec![plain(None), plain(Some(0)), plain(Some(0)), plain(Some(1))],
3866            warm_default(4),
3867            &[vec![1, 2], vec![3], vec![], vec![]],
3868        );
3869        let positions = vec![
3870            pos(0.0, 0.0),
3871            pos(10.0, 10.0),
3872            pos(20.0, 20.0),
3873            pos(30.0, 30.0),
3874        ];
3875        (tree, positions)
3876    }
3877
3878    #[test]
3879    fn shift_subtree_position_zero_delta_is_identity() {
3880        let (tree, mut positions) = shift_fixture();
3881        let before = positions.clone();
3882        shift_subtree_position(0, pos(0.0, 0.0), &tree, &mut positions);
3883        assert_eq!(positions, before);
3884    }
3885
3886    #[test]
3887    fn shift_subtree_position_moves_the_subtree_and_leaves_siblings_alone() {
3888        let (tree, mut positions) = shift_fixture();
3889        shift_subtree_position(1, pos(5.0, -3.0), &tree, &mut positions);
3890
3891        assert_eq!(positions[0], pos(0.0, 0.0), "parent untouched");
3892        assert_eq!(positions[1], pos(15.0, 7.0), "shifted node");
3893        assert_eq!(positions[2], pos(20.0, 20.0), "sibling untouched");
3894        assert_eq!(positions[3], pos(35.0, 27.0), "descendant follows");
3895    }
3896
3897    #[test]
3898    fn shift_subtree_position_out_of_range_node_is_a_noop() {
3899        let (tree, mut positions) = shift_fixture();
3900        let before = positions.clone();
3901        shift_subtree_position(99, pos(5.0, 5.0), &tree, &mut positions);
3902        shift_subtree_position(usize::MAX, pos(5.0, 5.0), &tree, &mut positions);
3903        assert_eq!(positions, before);
3904    }
3905
3906    #[test]
3907    fn shift_subtree_position_skips_nodes_without_a_stored_position() {
3908        let (tree, _) = shift_fixture();
3909        // Only node 0 has a position; 1..3 are missing from the vec entirely.
3910        let mut positions = vec![pos(1.0, 1.0)];
3911        shift_subtree_position(0, pos(2.0, 2.0), &tree, &mut positions);
3912        assert_eq!(positions.len(), 1, "the vec is not grown by the shift");
3913        assert_eq!(positions[0], pos(3.0, 3.0));
3914    }
3915
3916    #[test]
3917    fn shift_subtree_position_leaves_the_unset_sentinel_unset() {
3918        // POSITION_UNSET is f32::MIN; adding a small delta to it is a no-op at
3919        // f32 precision, so an un-positioned node stays "unset" after a shift
3920        // instead of turning into a bogus near-MIN coordinate.
3921        let (tree, _) = shift_fixture();
3922        let mut positions = vec![pos(0.0, 0.0), POSITION_UNSET, POSITION_UNSET, POSITION_UNSET];
3923        shift_subtree_position(0, pos(7.0, 9.0), &tree, &mut positions);
3924
3925        assert_eq!(pos_get(&positions, 0), Some(pos(7.0, 9.0)));
3926        assert!(pos_get(&positions, 1).is_none());
3927        assert!(pos_get(&positions, 3).is_none());
3928    }
3929
3930    #[test]
3931    fn shift_subtree_position_nan_delta_is_confined_to_the_subtree() {
3932        let (tree, mut positions) = shift_fixture();
3933        shift_subtree_position(1, pos(f32::NAN, f32::NAN), &tree, &mut positions);
3934
3935        assert!(positions[1].x.is_nan() && positions[1].y.is_nan());
3936        assert!(positions[3].x.is_nan(), "NaN reaches the descendant");
3937        assert!(!positions[2].x.is_nan(), "the sibling is untouched");
3938        assert_eq!(positions[0], pos(0.0, 0.0));
3939    }
3940
3941    #[test]
3942    fn shift_subtree_position_walks_a_deep_chain_without_blowing_the_stack() {
3943        const DEPTH: usize = 500;
3944        let mut nodes = vec![plain(None)];
3945        let mut child_lists: Vec<Vec<usize>> = vec![vec![1]];
3946        for i in 1..DEPTH {
3947            nodes.push(plain(Some(i - 1)));
3948            child_lists.push(if i + 1 < DEPTH { vec![i + 1] } else { vec![] });
3949        }
3950        let tree = build_tree(nodes, warm_default(DEPTH), &child_lists);
3951        let mut positions = vec![pos(0.0, 0.0); DEPTH];
3952
3953        shift_subtree_position(0, pos(1.0, 2.0), &tree, &mut positions);
3954
3955        assert_eq!(positions[0], pos(1.0, 2.0));
3956        assert_eq!(positions[DEPTH - 1], pos(1.0, 2.0));
3957    }
3958
3959    // ==================================================================
3960    // position_bfc_child_descendants / position_flex_child_descendants
3961    // ==================================================================
3962
3963    #[test]
3964    fn position_bfc_child_descendants_out_of_range_node_is_a_noop() {
3965        let (tree, mut positions) = shift_fixture();
3966        let before = positions.clone();
3967        position_bfc_child_descendants(&tree, 99, pos(1.0, 1.0), &mut positions);
3968        assert_eq!(positions, before);
3969    }
3970
3971    #[test]
3972    fn position_bfc_child_descendants_converts_relative_to_absolute() {
3973        // 0 -> [1] -> [2]; node 1 has a 2px border + 3px padding on top/left.
3974        let bp = box_props(
3975            edges(0.0, 0.0, 0.0, 0.0),
3976            edges(2.0, 0.0, 0.0, 2.0),
3977            edges(3.0, 0.0, 0.0, 3.0),
3978        );
3979        let mut warm = warm_default(3);
3980        warm[1].relative_position = Some(pos(10.0, 20.0));
3981        warm[2].relative_position = Some(pos(1.0, 1.0));
3982        let tree = build_tree(
3983            vec![
3984                plain(None),
3985                hot(Some(0), None, Some(size(50.0, 50.0)), &bp),
3986                plain(Some(1)),
3987            ],
3988            warm,
3989            &[vec![1], vec![2], vec![]],
3990        );
3991
3992        let mut positions: PositionVec = Vec::new();
3993        position_bfc_child_descendants(&tree, 0, pos(100.0, 200.0), &mut positions);
3994
3995        // child: content-box origin + relative
3996        assert_eq!(pos_get(&positions, 1), Some(pos(110.0, 220.0)));
3997        // grandchild: child's own content box (abs + border + padding) + relative
3998        assert_eq!(pos_get(&positions, 2), Some(pos(110.0 + 5.0 + 1.0, 220.0 + 5.0 + 1.0)));
3999    }
4000
4001    #[test]
4002    fn position_bfc_child_descendants_defaults_a_missing_relative_position_to_the_origin() {
4003        let tree = build_tree(
4004            vec![plain(None), plain(Some(0))],
4005            warm_default(2), // relative_position: None
4006            &[vec![1], vec![]],
4007        );
4008        let mut positions: PositionVec = Vec::new();
4009        position_bfc_child_descendants(&tree, 0, pos(7.0, 8.0), &mut positions);
4010        assert_eq!(pos_get(&positions, 1), Some(pos(7.0, 8.0)));
4011    }
4012
4013    #[test]
4014    fn position_flex_child_descendants_rejects_a_dangling_child_index() {
4015        // children_arena points at a node that does not exist → Err, not a panic.
4016        let mut tree = build_tree(vec![plain(None)], warm_default(1), &[vec![99]]);
4017        let mut positions: PositionVec = Vec::new();
4018        let r = position_flex_child_descendants(
4019            &mut tree,
4020            0,
4021            pos(0.0, 0.0),
4022            size(100.0, 100.0),
4023            &mut positions,
4024        );
4025        assert!(r.is_err());
4026    }
4027
4028    #[test]
4029    fn position_flex_child_descendants_positions_children_and_grandchildren() {
4030        let mut warm = warm_default(3);
4031        warm[1].relative_position = Some(pos(5.0, 5.0));
4032        warm[2].relative_position = Some(pos(2.0, 2.0));
4033        let mut tree = build_tree(
4034            vec![plain(None), plain(Some(0)), plain(Some(1))],
4035            warm,
4036            &[vec![1], vec![2], vec![]],
4037        );
4038
4039        let mut positions: PositionVec = Vec::new();
4040        position_flex_child_descendants(
4041            &mut tree,
4042            0,
4043            pos(50.0, 60.0),
4044            size(100.0, 100.0),
4045            &mut positions,
4046        )
4047        .expect("well-formed tree");
4048
4049        assert_eq!(pos_get(&positions, 1), Some(pos(55.0, 65.0)));
4050        assert_eq!(pos_get(&positions, 2), Some(pos(57.0, 67.0)));
4051    }
4052
4053    // ==================================================================
4054    // collect_children_dom_ids / layout_relevant_child_count
4055    // ==================================================================
4056
4057    #[test]
4058    fn collect_children_dom_ids_skips_display_none_children() {
4059        // body(0) > [ div(1), div.hide(2), div(3) ]
4060        let sd = styled(
4061            Dom::create_body()
4062                .with_child(Dom::create_div())
4063                .with_child(div_class("hide"))
4064                .with_child(Dom::create_div()),
4065            ".hide { display: none; }",
4066        );
4067
4068        let children = collect_children_dom_ids(&sd, NodeId::ZERO);
4069        assert_eq!(children, vec![NodeId::new(1), NodeId::new(3)]);
4070    }
4071
4072    #[test]
4073    fn collect_children_dom_ids_for_leaves_and_unknown_parents_is_empty() {
4074        let sd = styled(Dom::create_body().with_child(Dom::create_div()), "");
4075
4076        // A leaf has no children.
4077        assert!(collect_children_dom_ids(&sd, NodeId::new(1)).is_empty());
4078        // An id past the end of the hierarchy must not panic.
4079        assert!(collect_children_dom_ids(&sd, NodeId::new(999)).is_empty());
4080        assert!(collect_children_dom_ids(&sd, NodeId::new(usize::MAX)).is_empty());
4081    }
4082
4083    #[test]
4084    fn layout_relevant_child_count_counts_only_boxes_the_builder_would_emit() {
4085        // body(0) > [ div(1), text " "(2), div.hide(3) ]
4086        let sd = styled(
4087            Dom::create_body()
4088                .with_child(Dom::create_div())
4089                .with_child(Dom::create_text(" "))
4090                .with_child(div_class("hide")),
4091            ".hide { display: none; }",
4092        );
4093        let children = [NodeId::new(1), NodeId::new(2), NodeId::new(3)];
4094
4095        // display:none is dropped, and the whitespace-only inline run between
4096        // block siblings collapses → only the first div survives.
4097        assert_eq!(layout_relevant_child_count(&sd, &children, NodeId::ZERO), 1);
4098        // Empty input → 0, no panic.
4099        assert_eq!(layout_relevant_child_count(&sd, &[], NodeId::ZERO), 0);
4100    }
4101
4102    // ==================================================================
4103    // is_whitespace_only_inline_run (predicate)
4104    // ==================================================================
4105
4106    #[test]
4107    fn is_whitespace_only_inline_run_empty_run_is_true() {
4108        let sd = whitespace_dom("");
4109        assert!(is_whitespace_only_inline_run(&sd, &[], NodeId::new(1)));
4110    }
4111
4112    #[test]
4113    fn is_whitespace_only_inline_run_detects_collapsible_whitespace_text() {
4114        let sd = whitespace_dom("");
4115        // " \n\t" — only ASCII whitespace.
4116        assert!(is_whitespace_only_inline_run(&sd, &[(0, NodeId::new(2))], NodeId::new(1)));
4117        // "hi" — real text.
4118        assert!(!is_whitespace_only_inline_run(&sd, &[(1, NodeId::new(3))], NodeId::new(1)));
4119        // A mixed run is not whitespace-only.
4120        assert!(!is_whitespace_only_inline_run(
4121            &sd,
4122            &[(0, NodeId::new(2)), (1, NodeId::new(3))],
4123            NodeId::new(1)
4124        ));
4125    }
4126
4127    #[test]
4128    fn is_whitespace_only_inline_run_rejects_elements_and_non_ascii_spaces() {
4129        let sd = whitespace_dom("");
4130        // A <div> in the run is not a text node → wrapper required.
4131        assert!(!is_whitespace_only_inline_run(&sd, &[(2, NodeId::new(4))], NodeId::new(1)));
4132        // U+00A0 NO-BREAK SPACE is *not* collapsible whitespace in CSS, and the
4133        // ASCII-only char set correctly treats it as real content.
4134        assert!(!is_whitespace_only_inline_run(&sd, &[(3, NodeId::new(5))], NodeId::new(1)));
4135    }
4136
4137    #[test]
4138    fn is_whitespace_only_inline_run_respects_white_space_pre() {
4139        // With `white-space: pre` on the parent, whitespace is significant and the
4140        // anonymous IFC wrapper must still be created.
4141        let sd = whitespace_dom(".p { white-space: pre; }");
4142        assert!(!is_whitespace_only_inline_run(&sd, &[(0, NodeId::new(2))], NodeId::new(1)));
4143
4144        // Sanity: the same run collapses without the `pre`.
4145        let sd = whitespace_dom(".p { white-space: normal; }");
4146        assert!(is_whitespace_only_inline_run(&sd, &[(0, NodeId::new(2))], NodeId::new(1)));
4147    }
4148
4149    // ==================================================================
4150    // reposition_clean_subtrees / reposition_block_flow_siblings
4151    // ==================================================================
4152
4153    #[test]
4154    fn reposition_clean_subtrees_ignores_empty_and_dangling_roots() {
4155        let sd = styled(Dom::create_body().with_child(Dom::create_div()), "");
4156        let tree = build_tree(
4157            vec![
4158                hot(None, Some(NodeId::ZERO), Some(size(100.0, 100.0)), &zero_box_props()),
4159                plain(Some(0)),
4160            ],
4161            warm_default(2),
4162            &[vec![1], vec![]],
4163        );
4164        let mut positions = vec![pos(0.0, 0.0), pos(1.0, 1.0)];
4165        let before = positions.clone();
4166
4167        // No dirty roots → nothing to reposition.
4168        reposition_clean_subtrees(&sd, &tree, &BTreeSet::new(), &mut positions);
4169        assert_eq!(positions, before);
4170
4171        // A layout root that isn't in the tree must not panic.
4172        let mut roots = BTreeSet::new();
4173        roots.insert(99);
4174        roots.insert(usize::MAX);
4175        reposition_clean_subtrees(&sd, &tree, &roots, &mut positions);
4176        assert_eq!(positions, before);
4177    }
4178
4179    #[test]
4180    fn reposition_clean_subtrees_skips_flex_parents() {
4181        let sd = styled(Dom::create_body().with_child(Dom::create_div()), "");
4182        let mut nodes = vec![
4183            hot(None, Some(NodeId::ZERO), Some(size(100.0, 100.0)), &zero_box_props()),
4184            plain(Some(0)),
4185        ];
4186        nodes[0].formatting_context = FormattingContext::Flex;
4187        let tree = build_tree(nodes, warm_default(2), &[vec![1], vec![]]);
4188
4189        let mut positions = vec![pos(0.0, 0.0), pos(1.0, 1.0)];
4190        let before = positions.clone();
4191        let mut roots = BTreeSet::new();
4192        roots.insert(1);
4193
4194        // Taffy owns flex layout; the sibling-repositioning shortcut is skipped.
4195        reposition_clean_subtrees(&sd, &tree, &roots, &mut positions);
4196        assert_eq!(positions, before);
4197    }
4198
4199    #[test]
4200    fn reposition_block_flow_siblings_stacks_clean_children_from_the_content_origin() {
4201        // body(0) is the parent; children 1 and 2 are clean, 3 is a grandchild.
4202        let sd = styled(Dom::create_body().with_child(Dom::create_div()), "");
4203        let parent_bp = box_props(
4204            edges(0.0, 0.0, 0.0, 0.0),
4205            edges(5.0, 5.0, 5.0, 5.0), // border — see the note below
4206            edges(3.0, 3.0, 3.0, 3.0), // padding
4207        );
4208        let tree = build_tree(
4209            vec![
4210                hot(None, Some(NodeId::ZERO), Some(size(200.0, 200.0)), &parent_bp),
4211                hot(Some(0), None, Some(size(200.0, 50.0)), &zero_box_props()),
4212                hot(Some(0), None, Some(size(200.0, 30.0)), &zero_box_props()),
4213                plain(Some(1)),
4214            ],
4215            warm_default(4),
4216            &[vec![1, 2], vec![3], vec![], vec![]],
4217        );
4218        let mut positions = vec![
4219            pos(10.0, 20.0), // parent (border-box origin)
4220            pos(0.0, 0.0),
4221            pos(0.0, 0.0),
4222            pos(0.0, 0.0),
4223        ];
4224
4225        reposition_block_flow_siblings(&sd, 0, &tree, &BTreeSet::new(), &mut positions);
4226
4227        // BEHAVIOUR PIN: the content origin is computed as parent_pos + PADDING
4228        // only — the parent's border is NOT added, unlike calculate_content_box_pos
4229        // (which adds border + padding to the same border-box origin). With a
4230        // bordered parent the clean siblings therefore land 5px up/left of where
4231        // the full layout pass would put them.
4232        assert_eq!(positions[1], pos(13.0, 23.0));
4233        assert_eq!(positions[2], pos(13.0, 73.0), "second child stacked below the first");
4234        // The grandchild moved with its parent's subtree.
4235        assert_eq!(positions[3], pos(13.0, 23.0));
4236    }
4237
4238    // ==================================================================
4239    // compute_counters
4240    // ==================================================================
4241
4242    #[test]
4243    fn compute_counters_on_an_empty_or_anonymous_tree_does_not_panic() {
4244        let sd = styled(Dom::create_body(), "");
4245        let mut counters: HashMap<(usize, String), i32> = HashMap::new();
4246
4247        // Root index past the end of the node list.
4248        let empty = build_tree(Vec::new(), Vec::new(), &[]);
4249        compute_counters(&sd, &empty, &mut counters);
4250        assert!(counters.is_empty());
4251
4252        // Anonymous root (no dom_node_id) with an anonymous child.
4253        let anon = build_tree(
4254            vec![plain(None), plain(Some(0))],
4255            warm_default(2),
4256            &[vec![1], vec![]],
4257        );
4258        compute_counters(&sd, &anon, &mut counters);
4259        assert!(counters.is_empty());
4260    }
4261
4262    #[test]
4263    fn compute_counters_increments_the_list_item_counter_in_document_order() {
4264        // body(0) > [ div.li(1), div.li(2) ]
4265        let sd = styled(
4266            Dom::create_body()
4267                .with_child(div_class("li"))
4268                .with_child(div_class("li")),
4269            ".li { display: list-item; }",
4270        );
4271        let tree = build_tree(
4272            vec![
4273                hot(None, Some(NodeId::ZERO), Some(size(100.0, 100.0)), &zero_box_props()),
4274                hot(Some(0), Some(NodeId::new(1)), Some(size(100.0, 10.0)), &zero_box_props()),
4275                hot(Some(0), Some(NodeId::new(2)), Some(size(100.0, 10.0)), &zero_box_props()),
4276            ],
4277            warm_default(3),
4278            &[vec![1, 2], vec![], vec![]],
4279        );
4280
4281        let mut counters: HashMap<(usize, String), i32> = HashMap::new();
4282        compute_counters(&sd, &tree, &mut counters);
4283
4284        // CSS Lists 3 § 3: `display: list-item` auto-increments "list-item".
4285        assert_eq!(counters.get(&(1, "list-item".to_string())), Some(&1));
4286        assert_eq!(counters.get(&(2, "list-item".to_string())), Some(&2));
4287        // The non-list-item root never enters a counter scope.
4288        assert_eq!(counters.get(&(0, "list-item".to_string())), None);
4289    }
4290
4291    #[test]
4292    fn compute_counters_leaves_plain_elements_alone() {
4293        let sd = styled(
4294            Dom::create_body().with_child(Dom::create_div()),
4295            "",
4296        );
4297        let tree = build_tree(
4298            vec![
4299                hot(None, Some(NodeId::ZERO), Some(size(100.0, 100.0)), &zero_box_props()),
4300                hot(Some(0), Some(NodeId::new(1)), Some(size(100.0, 10.0)), &zero_box_props()),
4301            ],
4302            warm_default(2),
4303            &[vec![1], vec![]],
4304        );
4305
4306        let mut counters: HashMap<(usize, String), i32> = HashMap::new();
4307        compute_counters(&sd, &tree, &mut counters);
4308        assert!(counters.is_empty(), "no counter-reset/increment → no counters");
4309    }
4310}