we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement incremental layout with dirty bit propagation

Add a dirty-bit system to the layout engine so that only modified subtrees
are re-laid-out instead of recomputing the entire layout tree each frame.

- Add `dirty` flag and `cached_available_width` to `LayoutBox`
- Add `DirtyTracker` struct with `mark_dirty()` (propagates to ancestors),
`mark_all_dirty()`, and `clear()` APIs
- `compute_layout()` skips clean subtrees, offsetting position if needed
- `pre_collapse_margins()` skips clean subtrees (already collapsed)
- Add `layout_incremental()` entry point that rebuilds the box tree,
transfers cached layout for clean nodes, and only runs expensive
layout computation on dirty subtrees
- Add layout counter (`layout_count()` / `reset_layout_count()`) for
metrics and testing
- Add tests: dirty tracker propagation, incremental skip verification,
correctness invariant (incremental == full layout), empty/all-dirty
edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+575 -1
+575 -1
crates/layout/src/lib.rs
··· 3 3 //! Builds a layout tree from a styled tree (DOM + computed styles) and positions 4 4 //! block-level elements vertically with proper inline formatting context. 5 5 6 - use std::collections::HashMap; 6 + use std::cell::Cell; 7 + use std::collections::{HashMap, HashSet}; 7 8 8 9 use we_css::values::Color; 9 10 use we_dom::{Document, NodeData, NodeId}; ··· 159 160 pub grid_row_start: GridPlacement, 160 161 pub grid_row_end: GridPlacement, 161 162 pub justify_self: JustifySelf, 163 + /// Dirty flag for incremental layout. When `true`, `compute_layout` will 164 + /// fully re-layout this box. When `false`, the cached layout results are 165 + /// reused (possibly with a position offset if a sibling changed height). 166 + pub dirty: bool, 167 + /// The `available_width` used in the last `compute_layout` call. 168 + /// If this changes for a clean box, it must be promoted to dirty because 169 + /// its width (and therefore children's layout) depends on it. 170 + cached_available_width: f32, 162 171 } 163 172 164 173 impl LayoutBox { ··· 247 256 grid_row_start: style.grid_row_start, 248 257 grid_row_end: style.grid_row_end, 249 258 justify_self: style.justify_self, 259 + dirty: true, 260 + cached_available_width: 0.0, 250 261 } 251 262 } 252 263 ··· 798 809 abs_cb: Rect, 799 810 float_ctx: Option<&FloatContext>, 800 811 ) { 812 + // --- Incremental layout: skip clean subtrees --- 813 + if !b.dirty { 814 + if (available_width - b.cached_available_width).abs() > 0.001 { 815 + // Containing block width changed → must re-layout. 816 + b.dirty = true; 817 + } else { 818 + // Compute where this box *should* be and offset if it moved. 819 + let content_x = x + b.margin.left + b.border.left + b.padding.left; 820 + let content_y = y + b.margin.top + b.border.top + b.padding.top; 821 + let dx = content_x - b.rect.x; 822 + let dy = content_y - b.rect.y; 823 + if dx.abs() > 0.001 || dy.abs() > 0.001 { 824 + offset_subtree(b, dx, dy); 825 + } 826 + return; 827 + } 828 + } 829 + 830 + bump_layout_counter(); 831 + 801 832 // Resolve percentage margins against containing block width. 802 833 // Only re-resolve percentages — absolute margins may have been modified 803 834 // by margin collapsing and must not be overwritten. ··· 937 968 layout_abspos_children(b, abs_cb, viewport_width, viewport_height, font, doc); 938 969 939 970 apply_relative_offset(b, available_width, viewport_height); 971 + 972 + b.dirty = false; 973 + b.cached_available_width = available_width; 940 974 } 941 975 942 976 /// For each direct child with `position: sticky`, record the parent's content ··· 1332 1366 /// value. The function walks bottom-up: children are pre-collapsed first, then 1333 1367 /// their (possibly enlarged) margins are folded into the parent. 1334 1368 fn pre_collapse_margins(b: &mut LayoutBox) { 1369 + // Clean subtrees already have correctly collapsed margins from the 1370 + // previous layout — skip them entirely. 1371 + if !b.dirty { 1372 + return; 1373 + } 1374 + 1335 1375 // Recurse into in-flow block children first (bottom-up). 1336 1376 for child in &mut b.children { 1337 1377 if is_in_flow(child) && is_block_level(child) { ··· 3622 3662 } 3623 3663 } 3624 3664 3665 + // --------------------------------------------------------------------------- 3666 + // Incremental layout 3667 + // --------------------------------------------------------------------------- 3668 + 3669 + thread_local! { 3670 + static LAYOUT_COUNTER: Cell<usize> = const { Cell::new(0) }; 3671 + } 3672 + 3673 + /// Return the number of boxes that received a full layout since the last 3674 + /// [`reset_layout_count`] call. 3675 + pub fn layout_count() -> usize { 3676 + LAYOUT_COUNTER.with(|c| c.get()) 3677 + } 3678 + 3679 + /// Reset the layout invocation counter to zero. 3680 + pub fn reset_layout_count() { 3681 + LAYOUT_COUNTER.with(|c| c.set(0)); 3682 + } 3683 + 3684 + fn bump_layout_counter() { 3685 + LAYOUT_COUNTER.with(|c| c.set(c.get() + 1)); 3686 + } 3687 + 3688 + /// Tracks which DOM nodes need re-layout. 3689 + /// 3690 + /// Call [`DirtyTracker::mark_dirty`] when a node's style, text content, or 3691 + /// child list changes. The tracker propagates the dirty flag upward through 3692 + /// DOM ancestors so that containing blocks are re-laid-out too. 3693 + pub struct DirtyTracker { 3694 + dirty_nodes: HashSet<NodeId>, 3695 + all_dirty: bool, 3696 + } 3697 + 3698 + impl DirtyTracker { 3699 + /// Create a new tracker. Nothing is marked dirty initially. 3700 + pub fn new() -> Self { 3701 + Self { 3702 + dirty_nodes: HashSet::new(), 3703 + all_dirty: false, 3704 + } 3705 + } 3706 + 3707 + /// Mark `node_id` and all of its DOM ancestors as needing re-layout. 3708 + pub fn mark_dirty(&mut self, node_id: NodeId, doc: &Document) { 3709 + if self.all_dirty { 3710 + return; 3711 + } 3712 + let mut current = Some(node_id); 3713 + while let Some(id) = current { 3714 + if !self.dirty_nodes.insert(id) { 3715 + // Already dirty — ancestors are already marked. 3716 + break; 3717 + } 3718 + current = doc.parent(id); 3719 + } 3720 + } 3721 + 3722 + /// Mark the entire tree dirty (e.g. on viewport resize or font load). 3723 + pub fn mark_all_dirty(&mut self) { 3724 + self.all_dirty = true; 3725 + } 3726 + 3727 + /// Returns `true` if `node_id` is marked dirty. 3728 + pub fn is_dirty(&self, node_id: NodeId) -> bool { 3729 + self.all_dirty || self.dirty_nodes.contains(&node_id) 3730 + } 3731 + 3732 + /// Returns `true` if no node has been marked dirty. 3733 + pub fn is_empty(&self) -> bool { 3734 + !self.all_dirty && self.dirty_nodes.is_empty() 3735 + } 3736 + 3737 + /// Clear all dirty flags (call after incremental layout completes). 3738 + pub fn clear(&mut self) { 3739 + self.dirty_nodes.clear(); 3740 + self.all_dirty = false; 3741 + } 3742 + } 3743 + 3744 + impl Default for DirtyTracker { 3745 + fn default() -> Self { 3746 + Self::new() 3747 + } 3748 + } 3749 + 3750 + /// Extract the `NodeId` associated with a layout box, if any. 3751 + fn box_node_id(b: &LayoutBox) -> Option<NodeId> { 3752 + match &b.box_type { 3753 + BoxType::Block(id) | BoxType::Inline(id) => Some(*id), 3754 + BoxType::TextRun { node, .. } => Some(*node), 3755 + BoxType::Anonymous => None, 3756 + } 3757 + } 3758 + 3759 + /// Bottom-up propagation: a box is dirty if its `NodeId` is in `tracker` or 3760 + /// if any of its children are dirty. Clean subtrees (no dirty descendants) 3761 + /// will have `dirty == false` afterwards. 3762 + fn propagate_dirty_flags(b: &mut LayoutBox, tracker: &DirtyTracker) { 3763 + let self_dirty = box_node_id(b).is_some_and(|id| tracker.is_dirty(id)); 3764 + 3765 + let mut any_child_dirty = false; 3766 + for child in &mut b.children { 3767 + propagate_dirty_flags(child, tracker); 3768 + if child.dirty { 3769 + any_child_dirty = true; 3770 + } 3771 + } 3772 + 3773 + b.dirty = self_dirty || any_child_dirty; 3774 + } 3775 + 3776 + /// Copy cached layout results from `old` into `new` for every box that is 3777 + /// **not** dirty in `new`. This lets `compute_layout` skip clean subtrees. 3778 + /// 3779 + /// For clean boxes, the layout dimensions and text lines are carried over from 3780 + /// the previous frame. The two trees must share the same structure for all 3781 + /// clean subtrees (guaranteed because only dirty subtrees can have structural 3782 + /// changes in the DOM). 3783 + fn transfer_cached_layout(old: &LayoutBox, new: &mut LayoutBox) { 3784 + if !new.dirty { 3785 + // Copy layout results that compute_layout would normally set. 3786 + new.rect = old.rect; 3787 + new.margin = old.margin; 3788 + new.padding = old.padding; 3789 + new.border = old.border; 3790 + new.lines = old.lines.clone(); 3791 + new.content_height = old.content_height; 3792 + new.relative_offset = old.relative_offset; 3793 + new.sticky_constraint = old.sticky_constraint; 3794 + new.cached_available_width = old.cached_available_width; 3795 + } 3796 + 3797 + // Always recurse into children when structure matches — a dirty parent 3798 + // can still have clean descendants that benefit from cached layout. 3799 + if old.children.len() == new.children.len() { 3800 + for (oc, nc) in old.children.iter().zip(new.children.iter_mut()) { 3801 + transfer_cached_layout(oc, nc); 3802 + } 3803 + } 3804 + } 3805 + 3806 + /// Shift every position in a subtree by `(dx, dy)`. 3807 + /// 3808 + /// Used for clean boxes whose parent layout moved them without changing their 3809 + /// internal dimensions. 3810 + fn offset_subtree(b: &mut LayoutBox, dx: f32, dy: f32) { 3811 + b.rect.x += dx; 3812 + b.rect.y += dy; 3813 + for line in &mut b.lines { 3814 + line.x += dx; 3815 + line.y += dy; 3816 + } 3817 + for child in &mut b.children { 3818 + offset_subtree(child, dx, dy); 3819 + } 3820 + } 3821 + 3822 + /// Incrementally re-layout a previously computed [`LayoutTree`]. 3823 + /// 3824 + /// Only the subtrees marked dirty in `tracker` are fully re-laid-out; clean 3825 + /// subtrees reuse their cached positions (with an offset adjustment when a 3826 + /// dirty sibling changes height). 3827 + /// 3828 + /// If the viewport dimensions changed, call [`DirtyTracker::mark_all_dirty`] 3829 + /// first — this forces a full re-layout. 3830 + /// 3831 + /// After this returns, call [`DirtyTracker::clear`] to prepare for the next 3832 + /// frame. 3833 + #[allow(clippy::too_many_arguments)] 3834 + pub fn layout_incremental( 3835 + tree: &mut LayoutTree, 3836 + tracker: &DirtyTracker, 3837 + styled_root: &StyledNode, 3838 + doc: &Document, 3839 + viewport_width: f32, 3840 + viewport_height: f32, 3841 + font: &Font, 3842 + image_sizes: &HashMap<NodeId, (f32, f32)>, 3843 + ) { 3844 + if tracker.all_dirty { 3845 + *tree = layout( 3846 + styled_root, 3847 + doc, 3848 + viewport_width, 3849 + viewport_height, 3850 + font, 3851 + image_sizes, 3852 + ); 3853 + return; 3854 + } 3855 + 3856 + if tracker.is_empty() { 3857 + return; 3858 + } 3859 + 3860 + // Build a fresh layout tree from the (possibly updated) styled tree. 3861 + // Building is cheap; the expensive part is compute_layout. 3862 + let mut new_root = match build_box(styled_root, doc, image_sizes) { 3863 + Some(b) => b, 3864 + None => return, 3865 + }; 3866 + 3867 + // Determine which boxes are dirty vs clean. 3868 + propagate_dirty_flags(&mut new_root, tracker); 3869 + 3870 + // Copy cached layout results from the old tree into clean boxes. 3871 + transfer_cached_layout(&tree.root, &mut new_root); 3872 + 3873 + // Pre-collapse margins. The function skips clean subtrees (their margins 3874 + // are already collapsed from the previous layout). 3875 + pre_collapse_margins(&mut new_root); 3876 + 3877 + // Run layout — compute_layout will skip clean subtrees. 3878 + let viewport_cb = Rect { 3879 + x: 0.0, 3880 + y: 0.0, 3881 + width: viewport_width, 3882 + height: viewport_height, 3883 + }; 3884 + compute_layout( 3885 + &mut new_root, 3886 + 0.0, 3887 + 0.0, 3888 + viewport_width, 3889 + viewport_width, 3890 + viewport_height, 3891 + font, 3892 + doc, 3893 + viewport_cb, 3894 + None, 3895 + ); 3896 + 3897 + tree.root = new_root; 3898 + tree.width = viewport_width; 3899 + tree.height = tree.root.margin_box_height(); 3900 + } 3901 + 3625 3902 #[cfg(test)] 3626 3903 mod tests { 3627 3904 use super::*; ··· 6583 6860 "Grid height should be >= 50px, got {}", 6584 6861 grid.rect.height 6585 6862 ); 6863 + } 6864 + 6865 + // --- Incremental layout tests --- 6866 + 6867 + #[test] 6868 + fn dirty_tracker_marks_ancestors() { 6869 + let mut doc = Document::new(); 6870 + let root = doc.root(); 6871 + let html = doc.create_element("html"); 6872 + let body = doc.create_element("body"); 6873 + let div = doc.create_element("div"); 6874 + let p = doc.create_element("p"); 6875 + doc.append_child(root, html); 6876 + doc.append_child(html, body); 6877 + doc.append_child(body, div); 6878 + doc.append_child(div, p); 6879 + 6880 + let mut tracker = DirtyTracker::new(); 6881 + tracker.mark_dirty(p, &doc); 6882 + 6883 + assert!(tracker.is_dirty(p)); 6884 + assert!(tracker.is_dirty(div)); 6885 + assert!(tracker.is_dirty(body)); 6886 + assert!(tracker.is_dirty(html)); 6887 + assert!(tracker.is_dirty(root)); 6888 + } 6889 + 6890 + #[test] 6891 + fn dirty_tracker_clear() { 6892 + let mut doc = Document::new(); 6893 + let root = doc.root(); 6894 + let el = doc.create_element("div"); 6895 + doc.append_child(root, el); 6896 + 6897 + let mut tracker = DirtyTracker::new(); 6898 + tracker.mark_dirty(el, &doc); 6899 + assert!(tracker.is_dirty(el)); 6900 + 6901 + tracker.clear(); 6902 + assert!(!tracker.is_dirty(el)); 6903 + assert!(tracker.is_empty()); 6904 + } 6905 + 6906 + #[test] 6907 + fn dirty_tracker_mark_all_dirty() { 6908 + let doc = Document::new(); 6909 + let root = doc.root(); 6910 + 6911 + let mut tracker = DirtyTracker::new(); 6912 + tracker.mark_all_dirty(); 6913 + 6914 + assert!(tracker.is_dirty(root)); 6915 + assert!(!tracker.is_empty()); 6916 + } 6917 + 6918 + #[test] 6919 + fn incremental_layout_skips_clean_subtrees() { 6920 + // Build a document with two sibling subtrees. 6921 + let html_str = r#"<!DOCTYPE html> 6922 + <html><body> 6923 + <div><p>First subtree</p></div> 6924 + <div><p>Second subtree</p></div> 6925 + </body></html>"#; 6926 + let doc = we_html::parse_html(html_str); 6927 + let font = test_font(); 6928 + let sheets = extract_stylesheets(&doc); 6929 + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 6930 + 6931 + // Full initial layout. 6932 + let mut tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 6933 + 6934 + // Count boxes in a full layout. 6935 + reset_layout_count(); 6936 + let _ = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 6937 + let full_count = layout_count(); 6938 + assert!(full_count > 0, "full layout should count boxes"); 6939 + 6940 + // Now do an incremental layout marking only the first <div> dirty. 6941 + // Find the first <div> NodeId. 6942 + let body_id = doc 6943 + .children(doc.root()) 6944 + .find(|id| doc.tag_name(*id) == Some("html")) 6945 + .and_then(|html| { 6946 + doc.children(html) 6947 + .find(|id| doc.tag_name(*id) == Some("body")) 6948 + }) 6949 + .unwrap(); 6950 + let first_div = doc 6951 + .children(body_id) 6952 + .find(|id| doc.tag_name(*id) == Some("div")) 6953 + .unwrap(); 6954 + 6955 + let mut tracker = DirtyTracker::new(); 6956 + tracker.mark_dirty(first_div, &doc); 6957 + 6958 + reset_layout_count(); 6959 + layout_incremental( 6960 + &mut tree, 6961 + &tracker, 6962 + &styled, 6963 + &doc, 6964 + 800.0, 6965 + 600.0, 6966 + &font, 6967 + &HashMap::new(), 6968 + ); 6969 + let incremental_count = layout_count(); 6970 + 6971 + assert!( 6972 + incremental_count < full_count, 6973 + "incremental layout ({incremental_count}) should lay out fewer boxes than full ({full_count})" 6974 + ); 6975 + } 6976 + 6977 + #[test] 6978 + fn incremental_layout_matches_full_layout() { 6979 + // Correctness invariant: incremental re-layout must produce the same 6980 + // result as a full re-layout. 6981 + let html_str = r#"<!DOCTYPE html> 6982 + <html><body> 6983 + <p>First</p> 6984 + <p>Second</p> 6985 + <p>Third</p> 6986 + </body></html>"#; 6987 + let doc = we_html::parse_html(html_str); 6988 + let font = test_font(); 6989 + let sheets = extract_stylesheets(&doc); 6990 + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 6991 + 6992 + // Initial layout. 6993 + let mut tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 6994 + 6995 + // Mark the second <p> dirty and do incremental layout. 6996 + let body_id = doc 6997 + .children(doc.root()) 6998 + .find(|id| doc.tag_name(*id) == Some("html")) 6999 + .and_then(|html| { 7000 + doc.children(html) 7001 + .find(|id| doc.tag_name(*id) == Some("body")) 7002 + }) 7003 + .unwrap(); 7004 + let second_p = doc.children(body_id).nth(1).unwrap(); 7005 + 7006 + let mut tracker = DirtyTracker::new(); 7007 + tracker.mark_dirty(second_p, &doc); 7008 + 7009 + layout_incremental( 7010 + &mut tree, 7011 + &tracker, 7012 + &styled, 7013 + &doc, 7014 + 800.0, 7015 + 600.0, 7016 + &font, 7017 + &HashMap::new(), 7018 + ); 7019 + 7020 + // Compare with full layout. 7021 + let full = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 7022 + 7023 + assert_eq!(tree.width, full.width); 7024 + assert!( 7025 + (tree.height - full.height).abs() < 0.01, 7026 + "heights differ: incremental={}, full={}", 7027 + tree.height, 7028 + full.height 7029 + ); 7030 + 7031 + // Compare all boxes. 7032 + let inc_boxes: Vec<_> = tree.iter().collect(); 7033 + let full_boxes: Vec<_> = full.iter().collect(); 7034 + assert_eq!( 7035 + inc_boxes.len(), 7036 + full_boxes.len(), 7037 + "box counts differ: incremental={}, full={}", 7038 + inc_boxes.len(), 7039 + full_boxes.len() 7040 + ); 7041 + for (i, (ib, fb)) in inc_boxes.iter().zip(full_boxes.iter()).enumerate() { 7042 + assert!( 7043 + (ib.rect.x - fb.rect.x).abs() < 0.01 7044 + && (ib.rect.y - fb.rect.y).abs() < 0.01 7045 + && (ib.rect.width - fb.rect.width).abs() < 0.01 7046 + && (ib.rect.height - fb.rect.height).abs() < 0.01, 7047 + "box {i} rects differ: inc={:?} vs full={:?}", 7048 + ib.rect, 7049 + fb.rect 7050 + ); 7051 + } 7052 + } 7053 + 7054 + #[test] 7055 + fn incremental_empty_tracker_is_noop() { 7056 + let html_str = "<!DOCTYPE html><html><body><p>Hello</p></body></html>"; 7057 + let doc = we_html::parse_html(html_str); 7058 + let font = test_font(); 7059 + let sheets = extract_stylesheets(&doc); 7060 + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 7061 + 7062 + let mut tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 7063 + let height_before = tree.height; 7064 + 7065 + let tracker = DirtyTracker::new(); 7066 + reset_layout_count(); 7067 + layout_incremental( 7068 + &mut tree, 7069 + &tracker, 7070 + &styled, 7071 + &doc, 7072 + 800.0, 7073 + 600.0, 7074 + &font, 7075 + &HashMap::new(), 7076 + ); 7077 + 7078 + assert_eq!(layout_count(), 0, "empty tracker should skip all layout"); 7079 + assert_eq!(tree.height, height_before); 7080 + } 7081 + 7082 + #[test] 7083 + fn incremental_all_dirty_equals_full() { 7084 + let html_str = "<!DOCTYPE html><html><body><p>Test</p></body></html>"; 7085 + let doc = we_html::parse_html(html_str); 7086 + let font = test_font(); 7087 + let sheets = extract_stylesheets(&doc); 7088 + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 7089 + 7090 + let mut tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 7091 + 7092 + let mut tracker = DirtyTracker::new(); 7093 + tracker.mark_all_dirty(); 7094 + 7095 + layout_incremental( 7096 + &mut tree, 7097 + &tracker, 7098 + &styled, 7099 + &doc, 7100 + 800.0, 7101 + 600.0, 7102 + &font, 7103 + &HashMap::new(), 7104 + ); 7105 + 7106 + let full = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 7107 + assert!( 7108 + (tree.height - full.height).abs() < 0.01, 7109 + "all-dirty incremental should match full layout" 7110 + ); 7111 + } 7112 + 7113 + #[test] 7114 + fn propagate_dirty_flags_marks_ancestors() { 7115 + let html_str = r#"<!DOCTYPE html> 7116 + <html><body> 7117 + <div><p>Content</p></div> 7118 + <div><p>Other</p></div> 7119 + </body></html>"#; 7120 + let doc = we_html::parse_html(html_str); 7121 + let font = test_font(); 7122 + let sheets = extract_stylesheets(&doc); 7123 + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 7124 + 7125 + let mut root = build_box(&styled, &doc, &HashMap::new()).unwrap(); 7126 + 7127 + // Find the <p> inside the first <div>. 7128 + let body_id = doc 7129 + .children(doc.root()) 7130 + .find(|id| doc.tag_name(*id) == Some("html")) 7131 + .and_then(|html| { 7132 + doc.children(html) 7133 + .find(|id| doc.tag_name(*id) == Some("body")) 7134 + }) 7135 + .unwrap(); 7136 + let first_div = doc 7137 + .children(body_id) 7138 + .find(|id| doc.tag_name(*id) == Some("div")) 7139 + .unwrap(); 7140 + let p_in_div = doc 7141 + .children(first_div) 7142 + .find(|id| doc.tag_name(*id) == Some("p")) 7143 + .unwrap(); 7144 + 7145 + let mut tracker = DirtyTracker::new(); 7146 + tracker.mark_dirty(p_in_div, &doc); 7147 + 7148 + propagate_dirty_flags(&mut root, &tracker); 7149 + 7150 + // Root (html) should be dirty — ancestor of dirty <p>. 7151 + assert!(root.dirty, "root should be dirty"); 7152 + // First div's subtree should be dirty. 7153 + let body_box = &root.children[0]; 7154 + assert!(body_box.dirty, "body should be dirty"); 7155 + let first_div_box = &body_box.children[0]; 7156 + assert!(first_div_box.dirty, "first div should be dirty"); 7157 + // Second div should be clean. 7158 + let second_div_box = &body_box.children[1]; 7159 + assert!(!second_div_box.dirty, "second div should be clean"); 6586 7160 } 6587 7161 }