Defer cyclic flex percentage sizing

This commit is contained in:
SGavrl
2026-07-31 02:45:30 +00:00
parent c216e29ac5
commit 72860049b8
2 changed files with 302 additions and 0 deletions

View File

@@ -2476,6 +2476,9 @@ fn layout_dom_once(
}
}
let deferred_cyclic_inline_sizes =
defer_cyclic_flex_inline_sizes(tree, &mut styles, root_fs, vw, vh);
if let Some(taffy_root) = build(tree, root_id, &mut taffy_tree, &mut id_map, &mut words, &mut engine, &mut ifc_items, &styles) {
// Taffy has no outer display type and only gives an auto-width
// Block root the initial-containing-block width. CSS blockifies
@@ -2815,6 +2818,22 @@ fn layout_dom_once(
);
}
let _ = taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure);
if resolve_deferred_flex_inline_sizes(
tree,
&mut taffy_tree,
&id_map,
&mut styles,
&deferred_cyclic_inline_sizes,
root_fs,
vw,
vh,
) {
let _ = taffy_tree.compute_layout_with_measure(
taffy_root,
available,
&mut measure,
);
}
if apply_multicol_balance(&mut taffy_tree, &ifc_items.multicol) {
let _ = taffy_tree.compute_layout_with_measure(
taffy_root,
@@ -2894,6 +2913,18 @@ fn layout_dom_once(
);
}
let _ = taffy_tree.compute_layout(taffy_root, available);
if resolve_deferred_flex_inline_sizes(
tree,
&mut taffy_tree,
&id_map,
&mut styles,
&deferred_cyclic_inline_sizes,
root_fs,
vw,
vh,
) {
let _ = taffy_tree.compute_layout(taffy_root, available);
}
if apply_multicol_balance(&mut taffy_tree, &ifc_items.multicol) {
let _ = taffy_tree.compute_layout(taffy_root, available);
}
@@ -5777,6 +5808,233 @@ fn apply_container_size_containment(
}
}
#[derive(Clone, Copy)]
struct DeferredCyclicInlineSize {
node: NodeId,
flex_item: NodeId,
slot: usize,
}
/// During a flex item's intrinsic-size calculation, percentages in descendant
/// inline sizes are cyclic: their containing block does not have its used
/// width yet. Resolving those percentages against the width inherited from a
/// more distant ancestor turns the temporary value into a permanent
/// min-content floor. Gecko instead measures the flex item first, then reflows
/// its descendants under the item's final main size.
///
/// Keep the non-percentage part for the intrinsic pass (percentage basis zero),
/// and remember enough information to resolve the complete expression after
/// flex sizing has selected the item's used width.
fn defer_cyclic_flex_inline_sizes(
tree: &DomTree,
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
root_fs: f32,
vw: f32,
vh: f32,
) -> Vec<DeferredCyclicInlineSize> {
let candidates: Vec<(NodeId, usize, String)> = styles
.iter()
.flat_map(|(&id, style)| {
[0usize, 2, 4].into_iter().filter_map(move |slot| {
style.size_expressions[slot]
.as_ref()
.filter(|expression| expression.contains('%'))
.cloned()
.map(|expression| (id, slot, expression))
})
})
.collect();
let mut deferred = Vec::new();
for (id, slot, expression) in candidates {
// Start at the expression's containing-box chain, not the sized
// node itself. A percentage-sized direct child of a nested flex
// container makes that container's contribution cyclic when the
// container is itself an item in an outer flex row.
let mut candidate = tree.get_node(id).and_then(|node| node.parent);
let mut flex_item = None;
while let Some(item) = candidate {
let mut parent = tree.get_node(item).and_then(|node| node.parent);
while let Some(parent_id) = parent {
let Some(parent_style) = styles.get(&parent_id) else {
break;
};
if parent_style.display_contents {
parent = tree.get_node(parent_id).and_then(|node| node.parent);
continue;
}
let row_flex = parent_style.display == crate::Display::Flex
&& !parent_style.internal_flex_container
&& !matches!(
parent_style.flex_direction,
Some(
taffy::FlexDirection::Column
| taffy::FlexDirection::ColumnReverse
)
);
let item_is_indefinite = styles.get(&item).map_or(false, |style| {
(!matches!(style.width, crate::Dimension::Px(_))
|| style.size_expressions[0]
.as_deref()
.is_some_and(|expression| expression.contains('%')))
&& style.float.is_none()
&& !matches!(
style.position,
Some(taffy::Position::Absolute)
)
});
if row_flex && item_is_indefinite {
flex_item = Some(item);
}
break;
}
if flex_item.is_some() {
break;
}
candidate = tree.get_node(item).and_then(|node| node.parent);
}
let Some(flex_item) = flex_item else {
continue;
};
let em = styles
.get(&id)
.and_then(|style| style.font_size)
.unwrap_or(root_fs);
let Some(intrinsic) =
crate::style::resolve_contextual_length(&expression, em, root_fs, vw, vh, 0.0)
else {
continue;
};
if let Some(style) = styles.get_mut(&id) {
let intrinsic = crate::Dimension::Px(intrinsic.max(0.0));
match slot {
0 => style.width = intrinsic,
2 => style.min_width = intrinsic,
// A cyclic percentage max-size behaves as its initial value
// during intrinsic contribution sizing; treating 50% as a
// zero maximum would erase real text/content minimums.
4 => style.max_width = crate::Dimension::Auto,
_ => unreachable!(),
}
}
deferred.push(DeferredCyclicInlineSize { node: id, flex_item, slot });
}
deferred
}
fn resolve_deferred_flex_inline_sizes(
tree: &DomTree,
taffy_tree: &mut TaffyTree<usize>,
id_map: &HashMap<taffy::NodeId, NodeId>,
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
deferred: &[DeferredCyclicInlineSize],
root_fs: f32,
vw: f32,
vh: f32,
) -> bool {
if deferred.is_empty() {
return false;
}
let taffy_by_dom: HashMap<NodeId, taffy::NodeId> =
id_map.iter().map(|(&taffy, &dom)| (dom, taffy)).collect();
// This is the final-reflow boundary: preserve the main size selected by
// the outer flex algorithm while descendants are resolved against it.
let flex_items: HashSet<NodeId> = deferred.iter().map(|entry| entry.flex_item).collect();
for flex_item in flex_items {
let Some(&taffy_id) = taffy_by_dom.get(&flex_item) else {
continue;
};
let Ok(layout) = taffy_tree.layout(taffy_id) else {
continue;
};
let Some(style) = styles.get(&flex_item) else {
continue;
};
let horizontal_edges =
style.padding.left + style.padding.right + style.border.left + style.border.right;
let used_declaration = if style.box_sizing == crate::BoxSizing::ContentBox {
(layout.size.width - horizontal_edges).max(0.0)
} else {
layout.size.width
};
if let Ok(current) = taffy_tree.style(taffy_id) {
let mut fixed = current.clone();
fixed.size.width = taffy::Dimension::length(used_declaration);
let _ = taffy_tree.set_style(taffy_id, fixed);
}
}
for entry in deferred {
let Some(expression) = styles
.get(&entry.node)
.and_then(|style| style.size_expressions[entry.slot].clone())
else {
continue;
};
let mut containing = tree.get_node(entry.node).and_then(|node| node.parent);
let basis = loop {
let Some(parent) = containing else {
break None;
};
let parent_style = styles.get(&parent);
if parent_style.is_some_and(|style| style.display_contents) {
containing = tree.get_node(parent).and_then(|node| node.parent);
continue;
}
let Some(&taffy_id) = taffy_by_dom.get(&parent) else {
containing = tree.get_node(parent).and_then(|node| node.parent);
continue;
};
let Ok(layout) = taffy_tree.layout(taffy_id) else {
break None;
};
let edges = parent_style.map_or(0.0, |style| {
style.padding.left + style.padding.right + style.border.left + style.border.right
});
break Some((layout.size.width - edges).max(0.0));
};
let Some(basis) = basis else {
continue;
};
let em = styles
.get(&entry.node)
.and_then(|style| style.font_size)
.unwrap_or(root_fs);
let Some(value) =
crate::style::resolve_contextual_length(&expression, em, root_fs, vw, vh, basis)
else {
continue;
};
let value = value.max(0.0);
if let Some(style) = styles.get_mut(&entry.node) {
let value = crate::Dimension::Px(value);
match entry.slot {
0 => style.width = value,
2 => style.min_width = value,
4 => style.max_width = value,
_ => unreachable!(),
}
}
let Some(&taffy_id) = taffy_by_dom.get(&entry.node) else {
continue;
};
if let Ok(current) = taffy_tree.style(taffy_id) {
let mut resolved = current.clone();
match entry.slot {
0 => resolved.size.width = taffy::Dimension::length(value),
2 => resolved.min_size.width = taffy::Dimension::length(value),
4 => resolved.max_size.width = taffy::Dimension::length(value),
_ => unreachable!(),
}
let _ = taffy_tree.set_style(taffy_id, resolved);
}
}
true
}
fn build(
tree: &DomTree,
id: NodeId,

View File

@@ -2371,6 +2371,50 @@ fn percentage_max_replaced_items_shrink_in_a_flex_row() {
);
}
#[test]
fn cyclic_descendant_percentages_do_not_inflate_a_flex_items_intrinsic_minimum() {
let tree = parse_html(
r#"
<style>
html, body { margin:0 }
#row { display:flex; width:1376px; gap:100px }
#left { width:100%; max-width:343px }
#left-floor { width:343px; height:20px }
#middle { display:flex; width:100%; gap:16px }
#middle > div { width:calc(33.333% - 16px); height:20px }
#right { flex:0 0 136px; height:20px }
</style>
<div id="row">
<div id="left"><div id="left-floor"></div></div>
<div id="middle"><div id="m1"></div><div id="m2"></div><div id="m3"></div></div>
<div id="right"></div>
</div>
"#,
);
let layout = layout_dom(&tree, (1600.0, 600.0));
let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()];
let row = rect("row");
let left = rect("left");
let middle = rect("middle");
let right = rect("right");
let middle_children = [rect("m1"), rect("m2"), rect("m3")];
assert!((row.width - 1376.0).abs() < 0.01, "{row:?}");
assert!((left.width - 343.0).abs() < 0.01, "{left:?}");
assert!(
(middle.width - 697.0).abs() < 0.01,
"cyclic descendant percentages must not become a 1376px intrinsic floor: \
row={row:?} left={left:?} middle={middle:?} right={right:?}"
);
assert!((right.width - 136.0).abs() < 0.01, "{right:?}");
for child in middle_children {
assert!(
(216.0..=217.0).contains(&child.width),
"the final reflow must resolve calc() against the 697px flex item: {child:?}"
);
}
}
/// The cyclic percentage affects only the min-content contribution. With
/// positive free space, the same boxes retain their natural max-content bases.
#[test]