Summary
A flex-wrap: wrap container whose main size is auto wraps its items into multiple lines when, during a content-size measurement round, the available main-axis space handed down by an ancestor is Definite and happens to equal the content's total main size (exceeding it by a few ulps). Per CSS, an auto-sized wrap container determines its line breaks from its own content, not from ancestor-derived available space during intrinsic sizing; Chrome keeps a single line for the same tree.
Observed while animating a child's height (one compute_layout per frame on a persistent tree, the normal way an animated UI drives layout): the container's cross size flickers to the two-line value for the frames where content_total > available by ~2 ulp, then returns. Single-shot layout of any final state does not misfire — the bug only shows through the measurement/caching interplay across the per-frame sequence.
Measured
Tree: a wrapped column container (flex-wrap: wrap, auto width/height, min-height: 92) holding a measured text-ish leaf 56 × 30.34 and a 320 × h leaf whose height animates 0 → 200 over 0.4 s; nested in a row-wrap container, a column section, a scroll container (overflow-y: scroll), and a 1920 × 1080 column root.
| frame |
panel height |
taffy 0.14.0 container width |
Chrome equivalent DOM |
| 21 |
187.22 |
320 |
320 |
| 22 |
191.32 |
376 (two columns) |
320 |
| 23 |
194.44 |
376 (two columns) |
320 |
| 24 |
196.88 |
320 |
320 |
Chrome values measured on the equivalent DOM (same styles/sizes) with getBoundingClientRect(); the panel never leaves the first column at any height. Reproduces with rounding enabled (default) and disabled.
Repro
// taffy = "0.14.0"
use taffy::geometry::{Point, Rect};
use taffy::prelude::*;
use taffy::{Size, TaffyTree};
fn pad(l: f32, r: f32, t: f32, b: f32) -> Rect<taffy::style::LengthPercentage> {
Rect { left: length(l), right: length(r), top: length(t), bottom: length(b) }
}
fn main() {
let mut tree: TaffyTree<()> = TaffyTree::new();
let button = tree.new_leaf(Style::default()).unwrap(); // measured 56 x 30.34
let panel = tree.new_leaf(Style {
size: Size { width: length(320.0), height: length(0.0) },
overflow: Point { x: taffy::style::Overflow::Visible, y: taffy::style::Overflow::Hidden },
..Default::default()
}).unwrap();
// wrapped COLUMN container with automatic width/height
let inner = tree.new_with_children(Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
flex_wrap: FlexWrap::Wrap,
gap: Size { width: length(0.0), height: length(10.0) },
align_items: Some(AlignItems::FLEX_START),
min_size: Size { width: auto(), height: length(92.0) },
..Default::default()
}, &[button, panel]).unwrap();
let outer = tree.new_with_children(Style {
display: Display::Flex,
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
gap: Size { width: length(28.0), height: length(0.0) },
align_items: Some(AlignItems::CENTER),
padding: pad(22.0, 22.0, 18.0, 18.0),
min_size: Size { width: auto(), height: length(92.0) },
..Default::default()
}, &[inner]).unwrap();
let section = tree.new_with_children(Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
padding: pad(22.0, 22.0, 22.0, 22.0),
..Default::default()
}, &[outer]).unwrap();
let body = tree.new_with_children(Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
padding: pad(56.0, 56.0, 32.0, 32.0),
overflow: Point { x: taffy::style::Overflow::Visible, y: taffy::style::Overflow::Scroll },
..Default::default()
}, &[section]).unwrap();
let root = tree.new_with_children(Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
size: Size { width: length(1920.0), height: length(1080.0) },
..Default::default()
}, &[body]).unwrap();
// Animate panel height 0 -> 200, one compute per frame on a persistent tree.
let dur = 0.4f32;
let frames = (dur * 60.0 * 1.3) as i32 + 6;
let mut anomalies = 0;
for i in 0..(frames + 3) {
let t = ((i - 3).max(0) as f32) / 60.0;
let frac = (t / dur).min(1.0);
let h = if i < 3 { 0.0 } else { 200.0 * (1.0 - (1.0 - frac) * (1.0 - frac)) };
let mut st = tree.style(panel).unwrap().clone();
st.size.height = length(h);
tree.set_style(panel, st).unwrap();
tree.compute_layout_with_measure(root, Size::MAX_CONTENT, |input, node, _ctx, style| {
if node == button {
let mut out = taffy::tree::LayoutOutput::DEFAULT;
out.size = Size { width: input.known_dimensions.width.unwrap_or(56.0), height: 30.34 };
out
} else {
taffy::compute_leaf_layout(input, style, |_, _| 0.0, |_, _| Size::ZERO)
}
}).unwrap();
let iw = tree.layout(inner).unwrap().size.width;
if (iw - 320.0).abs() > 0.5 {
anomalies += 1;
println!("frame {i:2} panel_h {h:9.5} inner width {iw:7.3} <-- wrapped (expected 320)");
}
}
println!("frames with wrong container width: {anomalies}");
}
Output:
frame 22 panel_h 191.31944 inner width 376.000 <-- wrapped (expected 320)
frame 23 panel_h 194.44444 inner width 376.000 <-- wrapped (expected 320)
frames with wrong container width: 2
Cause
Debug-feature trace of the misfiring round (frame 22, panel_h = 191.31944):
NodeId(7299): ComputeSize
NodeId(7299): sizing_mode ContentSize
NodeId(7299): known_dimensions Size { width: None, height: None }
NodeId(7299): parent_size Size { width: None, height: Some(231.65942) }
NodeId(7299): available_space Size { width: MaxContent, height: Definite(231.65942) }
NodeId(7299): ... item heights 30.34 and 191.31944, gap 10
NodeId(7299): collect_flex_lines
NodeId(7299): RESULT Size { width: 376.0, height: 231.65942 }
Content total is 30.34 + 10 + 191.31944 = 231.65944, exceeding the available 231.65942 by 2 ulp, so the second item is pushed to a new line. The available value itself is ancestor-derived (the enclosing row container was measured with known_dimensions.height = Some(267.65942), minus its 18 + 18 padding).
The chain that lets this round wrap at all:
src/compute/flexbox.rs:306-308 normalizes definiteness as is_definite || known_dimension.is_none() — with known_dimensions = None the round is treated as having a definite main size, so
- the
collect_flex_lines single-line gate at src/compute/flexbox.rs:1102 (!constants.is_wrap || !constants.known_main_size_is_definite) does not force a single line, and
- the wrap constraint at
src/compute/flexbox.rs:1187 falls back to the ancestor-derived definite available space (the container has no explicit main size / max main size).
But per CSS, wrapping is driven by the container's own resolved main size (or max main size when content-sized); an auto-sized container being intrinsically measured should not break lines against available space that merely leaks in from an ancestor's measurement round. Chrome's behavior matches that reading.
Summary
A
flex-wrap: wrapcontainer whose main size isautowraps its items into multiple lines when, during a content-size measurement round, the available main-axis space handed down by an ancestor isDefiniteand happens to equal the content's total main size (exceeding it by a few ulps). Per CSS, an auto-sized wrap container determines its line breaks from its own content, not from ancestor-derived available space during intrinsic sizing; Chrome keeps a single line for the same tree.Observed while animating a child's height (one
compute_layoutper frame on a persistent tree, the normal way an animated UI drives layout): the container's cross size flickers to the two-line value for the frames wherecontent_total > availableby ~2 ulp, then returns. Single-shot layout of any final state does not misfire — the bug only shows through the measurement/caching interplay across the per-frame sequence.Measured
Tree: a wrapped column container (
flex-wrap: wrap, auto width/height,min-height: 92) holding a measured text-ish leaf56 × 30.34and a320 × hleaf whose height animates 0 → 200 over 0.4 s; nested in a row-wrap container, a column section, a scroll container (overflow-y: scroll), and a1920 × 1080column root.Chrome values measured on the equivalent DOM (same styles/sizes) with
getBoundingClientRect(); the panel never leaves the first column at any height. Reproduces with rounding enabled (default) and disabled.Repro
Output:
Cause
Debug-feature trace of the misfiring round (frame 22,
panel_h = 191.31944):Content total is
30.34 + 10 + 191.31944 = 231.65944, exceeding the available231.65942by 2 ulp, so the second item is pushed to a new line. The available value itself is ancestor-derived (the enclosing row container was measured withknown_dimensions.height = Some(267.65942), minus its18 + 18padding).The chain that lets this round wrap at all:
src/compute/flexbox.rs:306-308normalizes definiteness asis_definite || known_dimension.is_none()— withknown_dimensions = Nonethe round is treated as having a definite main size, socollect_flex_linessingle-line gate atsrc/compute/flexbox.rs:1102(!constants.is_wrap || !constants.known_main_size_is_definite) does not force a single line, andsrc/compute/flexbox.rs:1187falls back to the ancestor-derived definite available space (the container has no explicit main size / max main size).But per CSS, wrapping is driven by the container's own resolved main size (or
max main sizewhen content-sized); an auto-sized container being intrinsically measured should not break lines against available space that merely leaks in from an ancestor's measurement round. Chrome's behavior matches that reading.