Skip to content

Auto-sized wrapped flex container wraps lines against ancestor-derived definite available space during content-size measurement #1175

Description

@15wtyuan

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:

  1. 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
  2. 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
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions