From acb59e7107980d46aed9cf9ec4e4a10b9ebe2a90 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 1 Sep 2026 05:17:45 +0800 Subject: [PATCH] fix(layout): paint flex and grid items atomically --- moli-layout/src/stacking.rs | 123 ++++++++++-------- .../page_vm/tests/grid_item_paint_order.rs | 79 +++++++++++ .../src/runtime/page_vm/tests/mod.rs | 1 + 3 files changed, 151 insertions(+), 52 deletions(-) create mode 100644 moli-renderer-v8/src/runtime/page_vm/tests/grid_item_paint_order.rs diff --git a/moli-layout/src/stacking.rs b/moli-layout/src/stacking.rs index 54c6a54ad..e173e7342 100644 --- a/moli-layout/src/stacking.rs +++ b/moli-layout/src/stacking.rs @@ -53,9 +53,15 @@ impl AtomicPaintEntry { } } +/// Paint level inherited while traversing a CSS atomic pseudo-context. +/// +/// Floats, positioned boxes, and inline-level atomic boxes keep their +/// non-stacking descendants together at one parent-context paint level. Real +/// stacking contexts are still hoisted before this grouping is applied. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum AtomicGroup { - Normal, +enum PaintGroup { + NormalFlow, + AtomicInline, Float, Positioned, } @@ -160,18 +166,15 @@ fn emit_context( fn collect_subtree( world: &LayoutWorld, id: LayoutBoxId, - inherited_group: Option, + inherited_atomic_group: Option, sequence: &mut usize, collection: &mut ContextCollection, ) where N: Copy + Debug + Eq + Hash, { let layout_box = &world.boxes[id.index()]; - let parent_is_flex_or_grid = layout_box.parent.is_some_and(|parent| { - let display = world.boxes[parent.index()].style.display(); - display.is_flex_container() || display.is_grid_container() - }); - if layout_box.creates_stacking_context(false, parent_is_flex_or_grid) { + let is_flex_or_grid_item = is_flex_or_grid_item(world, id); + if layout_box.creates_stacking_context(false, is_flex_or_grid_item) { let context = ChildContext { id, z_index: layout_box.style.explicit_z_index().unwrap_or(0), @@ -187,15 +190,27 @@ fn collect_subtree( return; } - let group = inherited_group.unwrap_or_else(|| { - if layout_box.style.position() != LayoutPosition::Static { - AtomicGroup::Positioned - } else if layout_box.style.is_floated() { - AtomicGroup::Float - } else { - AtomicGroup::Normal - } - }); + // Positioned descendants escape the pseudo-context of an atomic inline or + // float and participate in the nearest real stacking context. Resolve + // that level before inheriting the atomic group; ordinary descendants + // remain inside their atomic ancestor. + let group = if layout_box.style.position() != LayoutPosition::Static { + PaintGroup::Positioned + } else { + inherited_atomic_group.unwrap_or_else(|| { + if is_flex_or_grid_item { + // CSS Flexbox/Grid paint each item as an atomic inline-level box. + // Chromium carries the same boundary as IsPaintedAtomically on + // the item's constraint space. Floats do not apply to flex/grid + // items, so this classification precedes the float level. + PaintGroup::AtomicInline + } else if layout_box.style.is_floated() { + PaintGroup::Float + } else { + PaintGroup::NormalFlow + } + }) + }; push_unit( collection, group, @@ -207,66 +222,70 @@ fn collect_subtree( ); push_unit( collection, - if group == AtomicGroup::Normal { - AtomicGroup::Normal - } else { - group - }, + group, PaintUnit { id, kind: UnitKind::Contents, sequence: next_sequence(sequence), }, ); - // Floats and positioned descendants are painted atomically at their - // ancestor's paint level. Ordinary in-flow descendants are not: each child - // must still classify itself as normal, floating, or positioned. Carrying - // `Normal` down here would incorrectly bury a positioned grandchild in the - // block-background/inline-content buckets. + // Atomic pseudo-context descendants inherit their ancestor's paint level. + // Ordinary in-flow descendants do not: each child must classify itself as + // normal, floating, atomic-inline, or positioned. Positioned descendants + // override an inherited pseudo-context at the start of this function. let descendant_group = match group { - AtomicGroup::Normal => None, - AtomicGroup::Float | AtomicGroup::Positioned => Some(group), + PaintGroup::NormalFlow => None, + PaintGroup::AtomicInline | PaintGroup::Float | PaintGroup::Positioned => Some(group), }; for child in ordered_children(world, id) { collect_subtree(world, child, descendant_group, sequence, collection); } if layout_box.collapsed_table_borders.is_some() { - let unit = PaintUnit { + push_unit( + collection, + group, + PaintUnit { + id, + kind: UnitKind::TableCollapsedBorders, + sequence: next_sequence(sequence), + }, + ); + } + push_unit( + collection, + group, + PaintUnit { id, - kind: UnitKind::TableCollapsedBorders, + kind: UnitKind::Outline, sequence: next_sequence(sequence), - }; - match group { - AtomicGroup::Normal => collection.table_collapsed_borders.push(unit), - AtomicGroup::Float => collection.floats.push(unit), - AtomicGroup::Positioned => collection.positioned.push(AtomicPaintEntry::Unit(unit)), - } - } - let outline = PaintUnit { - id, - kind: UnitKind::Outline, - sequence: next_sequence(sequence), - }; - match group { - AtomicGroup::Normal => collection.outlines.push(outline), - AtomicGroup::Float => collection.floats.push(outline), - AtomicGroup::Positioned => collection.positioned.push(AtomicPaintEntry::Unit(outline)), - } + }, + ); } -fn push_unit(collection: &mut ContextCollection, group: AtomicGroup, unit: PaintUnit) { +fn push_unit(collection: &mut ContextCollection, group: PaintGroup, unit: PaintUnit) { match group { - AtomicGroup::Normal => match unit.kind { + PaintGroup::NormalFlow => match unit.kind { UnitKind::Background => collection.block_backgrounds.push(unit), UnitKind::TableCollapsedBorders => collection.table_collapsed_borders.push(unit), UnitKind::Contents => collection.inline_contents.push(unit), UnitKind::Outline => collection.outlines.push(unit), }, - AtomicGroup::Float => collection.floats.push(unit), - AtomicGroup::Positioned => collection.positioned.push(AtomicPaintEntry::Unit(unit)), + PaintGroup::AtomicInline => collection.inline_contents.push(unit), + PaintGroup::Float => collection.floats.push(unit), + PaintGroup::Positioned => collection.positioned.push(AtomicPaintEntry::Unit(unit)), } } +fn is_flex_or_grid_item(world: &LayoutWorld, id: LayoutBoxId) -> bool +where + N: Copy + Debug + Eq + Hash, +{ + world.boxes[id.index()].parent.is_some_and(|parent| { + let display = world.boxes[parent.index()].style.display(); + display.is_flex_container() || display.is_grid_container() + }) +} + fn emit_units(units: Vec, events: &mut Vec) { for unit in units { emit_unit(unit, events); diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/grid_item_paint_order.rs b/moli-renderer-v8/src/runtime/page_vm/tests/grid_item_paint_order.rs new file mode 100644 index 000000000..727732985 --- /dev/null +++ b/moli-renderer-v8/src/runtime/page_vm/tests/grid_item_paint_order.rs @@ -0,0 +1,79 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn screenshot_paints_flex_and_grid_items_as_atomic_inline_level_boxes() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let mut page_vm = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/flex-grid-atomic-paint.html")?, + ); + page_vm.vm_mut().eval( + r#" +document.head.innerHTML = ``; +const content = className => ``; +document.body.innerHTML = ` +
${content('content')}
+
${content('content')}
+
${content('content')}
+
${content('content')}
+
${content('content')}
+
`; +'installed' +"#, + )?; + page_vm.vm_mut().sync_live_document_style_sources(); + let snapshot = page_vm + .vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(120, 660, 1.0))? + .expect("flex/grid atomic-paint fixture should retain a layout root"); + let raster = moli_paint::raster_snapshot(&snapshot)?; + let pixel = |x: u32, y: u32| { + let index = ((y * raster.width + x) * 4) as usize; + <[u8; 4]>::try_from(&raster.rgba[index..index + 4]).expect("RGBA pixel") + }; + + for (label, y) in [ + ("grid item", 50), + ("inline-grid item", 160), + ("flex item", 270), + ("order-modified grid item", 380), + ] { + assert_eq!( + pixel(50, y), + [0, 128, 0, 255], + "{label} descendants must not escape the item's atomic paint boundary", + ); + } + assert_eq!( + pixel(50, 490), + [255, 0, 0, 255], + "a non-auto z-index on a static grid item must still establish a stacking context", + ); + assert_eq!( + pixel(50, 600), + [255, 0, 0, 255], + "a positioned descendant of an atomic grid item must participate in the parent stacking context", + ); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("flex/grid atomic-paint fixture should run"); +} diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs index a9cf659a9..77ceb8fa2 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs @@ -125,6 +125,7 @@ mod fetch_xhr; mod file_entry_file_callback; mod file_system_directory_reader; mod grid_item_box_generation; +mod grid_item_paint_order; mod grid_resolved_track_values; mod hash_change_delivery; mod history_traversal;