Skip to content

Implement flex-wrap: balance (CSS Flexbox Level 2) - #1105

Merged
nicoburns merged 16 commits into
mainfrom
devin/1786705691-flex-wrap-balance
Aug 14, 2026
Merged

Implement flex-wrap: balance (CSS Flexbox Level 2)#1105
nicoburns merged 16 commits into
mainfrom
devin/1786705691-flex-wrap-balance

Conversation

@nicoburns

@nicoburns nicoburns commented Aug 14, 2026

Copy link
Copy Markdown
Member

Objective

Implement draft CSS Flexbox Level 2 flex-wrap: balance support, along with the related flex-line-count property (per the CSSWG resolution in #13414). Both are gated behind a new on-by-default flexbox_balance cargo feature (depending on flexbox) so binary-size-sensitive consumers can opt out; with the feature disabled there is zero behavior/code-size change.

Context

Style / parsing

  • FlexWrap gains Balance and BalanceReverse variants (still one byte). The CSS parser accepts the multi-keyword grammar nowrap | [ wrap | wrap-reverse ] || balance, so balance, wrap balance, wrap-reverse balance (in either order) all parse.
  • Style::flex_line_count: u16 (default 1) + FlexboxContainerStyle::flex_line_count(): the minimum number of lines requested for a multi-line container.

Line breaking — non-balancing containers keep the existing greedy path untouched (the balance dispatch lives at the call site in compute_preliminary). When balancing, collect_balanced_flex_lines implements the spec's balancing algorithm from scratch: items are divided into exactly

line_count = max(greedy_line_count, min(flex_line_count, item_count))

contiguous lines (≥1 item each, no line exceeding the container's inner main size unless it holds a single overflowing item), minimizing the sum of squared errors (error = line size − inner main size) with the spec's lexicographic tie-break (most items on line 1, then line 2, …). Since every division has the same line count, total item size, and total gap size, this is equivalent to minimizing the sum of squared line sizes, which is what the implementation scores: it needs no target size, so an indefinite line limit needs no surrogate value (all finite sizes are kept exactly — f32f64 conversion is exact and squares of f32 values fit comfortably in f64), and there is no cancellation. The DP runs over (remaining line count, first item of suffix) on prefix sums of the already-computed hypothetical outer main sizes (only negative sizes floored at zero per spec), followed by a front-to-back readback that picks the longest line achieving the minimum at each step — implementing the tie-break exactly. The spec's zero-sized-item rule (a zero-sized item is glued onto the end of the preceding line whenever a valid division can do so — observable with nonzero gaps, where moving a zero item across a boundary moves a gap and changes the score) is enforced as a hard constraint on line ends rather than left to the tie-break. Arithmetic is f64. No extra child measurement passes are performed — balancing is pure arithmetic over sizes the algorithm already computed, so there are no performance concerns for non-balancing layouts.

Performance — the DP is accelerated with the divide-and-conquer optimization (the squared-error cost satisfies the concave quadrangle inequality, making the largest minimizing line end monotone in the suffix start), giving O(k·n·log n) time and O(k·n) transient memory (u32 breakpoints + two f64 rows; the zero-item flags are stored alongside the prefix sums in a single Vec<(f64, bool)>). When the selected line count equals the item count, the one-item-per-line partition is returned directly with no DP — this covers flex-line-count >= item_count (e.g. 1000 zero-sized items with flex-line-count: 1000) and all-oversized items. Per-row infeasible suffixes (greedy count exceeds the row's line count) form a prefix and are excluded from the recursion so every solved state is feasible. Line ends preceding a zero-sized item are excluded from the monotone divide-and-conquer scan (the zero-item rule's validity is not monotone in the line end, unlike the squared-size cost) and the single end the rule allows per line start is merged in separately, preserving the O(k·n·log n) bound. The naive O(k·n²) DP is kept as a test oracle; a deterministic randomized property test (compute::flexbox::balance::tests::matches_naive_dp) compares the optimized implementation against it on 5000 tie-heavy inputs (mixing zero/negative item sizes, nonzero gaps, and indefinite limits). Measured full-layout times (release): 1000 items / flex-line-count: 1000 ≈ 0.4ms (short-circuit), 1000 / 500 ≈ 10ms, 10000 / 5000 ≈ 1.3s (comparable to Chrome's O(n·m) at this scale).

Container main sizing — fit-content sizing of a multi-line container under definite available space normally stretches to the available space, but a balanced container whose lines all fit (e.g. via flex-line-count) instead uses its balanced max-content size (the longest line when items are balanced across the minimum line count with no size limit), clamped to the available space. This matches Chrome and fixes WPT balance-line-count-intrinsic-001/003/004 when run through Blitz.

flex-line-count cross-axis behavior — when the requested line count N > 1 on any multi-line container (wrap, wrap-reverse, balance, balance-reverse — internally is_balance controls line-breaking dispatch while line_count: Option<u16> is present for every multi-line container and controls this adjustment; nowrap leaves it None), definite cross-axis available space for measuring items is divided as (cross_space − (N−1)·cross_gap) / N (percentages are unaffected), per the spec: "This available space adjustment is the only effect flex-line-count has on non-balance flexboxes." The division also applies to the stretch size a bare fit-content cross size is measured against (while percentages continue to resolve against the full container size). Note that Chrome 152 does not yet apply the adjustment to non-balance containers, so the wrap/wrap-reverse division behavior is covered by hand-written measure-function tests (tests/hand_written/flex_line_count.rs) rather than gentests.

Testing — 34 gentest fixtures (test_fixtures/flex/balance_*.html) generated against Chrome for Testing 152, covering row/column, wrap/wrap-reverse combinations, gaps, ties, zero-sized and oversized items, percentages, intrinsic sizing, and flex-line-count (including counts exceeding item counts, identical items, cross-space division, fit-content under definite available space, and no line-breaking effect on plain wrap/nowrap). The second batch of fixtures fills gaps identified by surveying the WPT css/css-flexbox/balance suite: negative margins (line-break flooring vs post-flex sizes), larger mixed-size sequences exercising the minimization + start-bias tie-break, zero-sized items adjacent to overflowing/perfectly-full lines, flex-grow after balancing, min-content/max-content intrinsic sizing (row + column) with flex-line-count, divided cross space affecting content measurement, percent+aspect-ratio sizing, and degenerate cases (single oversized item, all-perfect-fit, all-oversized). Generated balance tests are #[cfg(feature = "flexbox_balance")]-gated like the grid tests. Hand-written tests cover cross-space division on non-balance containers and the zero-sized-item rule with nonzero gaps via unit tests (a place where Chrome 152 diverges from the spec: for limit 100, gap 10, items [70, 0, 20], Chrome picks [70] / [0, 20] while the spec's zero-item rule requires [70, 0] / [20]), plus a gentest fixture for exact fits above 2^24 (two 10,000,000px items in a 20,000,000px container stay on one line). Verified: cargo test --workspace (5817 generated tests), --all-features, --no-default-features feature combos, clippy -D warnings, rustfmt, docs build, and MSRV (1.71) build.

Not representable as static gentests: the WPT balance-dynamic-* tests (style mutation + relayout) and writing-mode/-webkit-box cases.

CHANGELOG.md updated under "Unreleased".

Feedback wanted

  • Whether dividing cross-axis available space for flex-line-count in all three measurement call sites (determine_flex_base_size, the content-sizing path of determine_container_main_size, and determine_hypothetical_cross_size) matches expectations — Chrome only observably applies it to definite cross space, which is what the gentests cover.
  • The nowrap fast-return in the parser accepts nowrap as the first keyword and stops; trailing keywords are rejected by parse_entirely at the declaration level rather than inside FlexWrap::from_css itself.

Link to Devin session: https://dioxus.staging.devinenterprise.com/sessions/4ec0069c23ac40dcaf9c8f7b274fe144
Requested by: @nicoburns

…el 2

Both are gated behind a new on-by-default 'flexbox_balance' cargo feature.
The balanced line breaking algorithm is a port of Chromium's FlexLineBreaker,
and generated tests verify layout output against Chrome for Testing.
@staging-devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

The naive dynamic program over (line count, suffix start) scanned every
candidate line end for every state, giving O(k*n^2) time (O(n^3) when
flex-line-count approaches the item count). The cost function satisfies
the concave quadrangle inequality, so the largest minimizing end is
monotone in the suffix start, and the divide-and-conquer optimization
computes each row in O(n log n), for O(k*n*log n) total.

Additionally, when the selected line count equals the item count the
partition is trivially one item per line and is returned directly,
covering both flex-line-count >= item count and every item overflowing.
Per-row infeasible states (suffixes whose greedy line count exceeds the
row's line count) form a prefix and are excluded from the recursion
range so every solved state is feasible.

The naive DP is kept as a test oracle: a deterministic randomized
property test compares the optimized implementation against it on
5000 tie-heavy inputs.
… rule

Finite f32 sizes and limits are now kept exactly as f64 rather than being
clamped to 2^24, with only negative item sizes floored at zero. Since every
division has the same line count, total item size, and total gap size,
minimizing the sum of squared errors is equivalent to minimizing the sum of
squared line sizes, which needs no target size and so handles indefinite
limits with no surrogate value.

The spec's zero-sized-item rule (a zero-sized item is glued to the end of
the preceding line whenever a valid division can do so) is now a hard
constraint on line ends rather than being left to the equal-score tie-break,
which only coincides with it when gaps are zero. Line ends preceding a zero
item are excluded from the divide-and-conquer scan (their validity is not
monotone, unlike the squared-size cost) and the single end the rule allows
per start is merged in separately.
@nicoburns nicoburns changed the title Implement flex-wrap: balance and flex-line-count from CSS Flexbox Level 2 Implement flex-wrap: balance (CSS Flexbox Level 2) Aug 14, 2026
A bare fit-content cross size is measured against its stretch size, which
was computed from the full container cross size, bypassing the
flex-line-count division applied to the cross-axis available space.
Percentages continue to resolve against the full container size.
@nicoburns
nicoburns merged commit 1bade66 into main Aug 14, 2026
32 checks passed
@nicoburns
nicoburns deleted the devin/1786705691-flex-wrap-balance branch August 14, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant