Implement flex-wrap: balance (CSS Flexbox Level 2) - #1105
Merged
Conversation
…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.
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
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.
flex-wrap: balance and flex-line-count from CSS Flexbox Level 2flex-wrap: balance (CSS Flexbox Level 2)
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.
This was referenced Aug 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Objective
Implement draft CSS Flexbox Level 2
flex-wrap: balancesupport, along with the relatedflex-line-countproperty (per the CSSWG resolution in #13414). Both are gated behind a new on-by-defaultflexbox_balancecargo feature (depending onflexbox) so binary-size-sensitive consumers can opt out; with the feature disabled there is zero behavior/code-size change.Context
Style / parsing
FlexWrapgainsBalanceandBalanceReversevariants (still one byte). The CSS parser accepts the multi-keyword grammarnowrap | [ wrap | wrap-reverse ] || balance, sobalance,wrap balance,wrap-reverse balance(in either order) all parse.Style::flex_line_count: u16(default1) +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_linesimplements the spec's balancing algorithm from scratch: items are divided into exactlycontiguous 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 —
f32→f64conversion is exact and squares off32values fit comfortably inf64), 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 isf64. 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 andO(k·n)transient memory (u32breakpoints + twof64rows; the zero-item flags are stored alongside the prefix sums in a singleVec<(f64, bool)>). When the selected line count equals the item count, the one-item-per-line partition is returned directly with no DP — this coversflex-line-count >= item_count(e.g. 1000 zero-sized items withflex-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 theO(k·n·log n)bound. The naiveO(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'sO(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 WPTbalance-line-count-intrinsic-001/003/004when run through Blitz.flex-line-countcross-axis behavior — when the requested line count N > 1 on any multi-line container (wrap,wrap-reverse,balance,balance-reverse— internallyis_balancecontrols line-breaking dispatch whileline_count: Option<u16>is present for every multi-line container and controls this adjustment;nowrapleaves itNone), 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 effectflex-line-counthas on non-balanceflexboxes." The division also applies to the stretch size a barefit-contentcross 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-balancecontainers, so thewrap/wrap-reversedivision 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-reversecombinations, gaps, ties, zero-sized and oversized items, percentages, intrinsic sizing, andflex-line-count(including counts exceeding item counts, identical items, cross-space division, fit-content under definite available space, and no line-breaking effect on plainwrap/nowrap). The second batch of fixtures fills gaps identified by surveying the WPTcss/css-flexbox/balancesuite: 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) withflex-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-balancecontainers 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-featuresfeature 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-boxcases.CHANGELOG.md updated under "Unreleased".
Feedback wanted
flex-line-countin all three measurement call sites (determine_flex_base_size, the content-sizing path ofdetermine_container_main_size, anddetermine_hypothetical_cross_size) matches expectations — Chrome only observably applies it to definite cross space, which is what the gentests cover.nowrapfast-return in the parser acceptsnowrapas the first keyword and stops; trailing keywords are rejected byparse_entirelyat the declaration level rather than insideFlexWrap::from_cssitself.Link to Devin session: https://dioxus.staging.devinenterprise.com/sessions/4ec0069c23ac40dcaf9c8f7b274fe144
Requested by: @nicoburns