Skip to content

gpui: max_w columns inside list items get stale heights and items paint over each other (taffy ≤0.12.2; fixed by bumping taffy to 0.13) #62498

Description

@HelgeSverre

TL;DR

Put wrapping text inside a max_w container inside a gpui::list item, and the item's height is computed as if the text wrapped at the uncapped pane width, while the text is laid out (and painted) at the capped width. Every paragraph that wraps to more lines at the capped width leaves the item one line short; over a long list this accumulates and the next items paint straight over the overflow — headings buried mid-paragraph, lines of two blocks interleaved. Wrong on the first frame, no interaction needed.

The root cause is in taffy, and it's already fixed upstream: when determining a flex child's basis, taffy ≤ 0.12.2 used the child's style size as a known dimension unclamped by the child's own min/max — so width: 100%; max_width: 560px is content-measured at the parent's full width, then laid out at 560. DioxusLabs/taffy#989 (merged 2026-07-25, released in taffy 0.13.0 on 2026-08-08) added the missing clamp — incidentally: its objective was aspect-ratio transferred sizes and it doesn't mention this failure mode. Zed's main currently pins taffy 0.12.2, which is affected. Bumping to 0.13.0 fixes this class of bug. Regression tests for the measure-function case: DioxusLabs/taffy#1095.

Reproduction

Side-by-side gpui app, corruption visible on launch: the left pane puts the cap inside each list item (w_full item → w_full().max_w(560.) column), the right pane renders identical content with the cap on the list element instead. In the left pane the red "NEXT BLOCK" heading paints on top of row 08 and the item after it lands inside row 09, while rows 09–11 continue underneath. Reproduces at rev b05f40c554 (taffy 0.12.1) and at d637307b (taffy 0.4.4).

main.rs + Cargo.toml (builds against rev b05f40c)
[package]
name = "gpui-text-overlap-repro"
version = "0.1.0"
edition = "2021"

[dependencies]
gpui = { git = "https://github.com/zed-industries/zed", rev = "b05f40c5546b47bcf9561136dc0fcdcd9968cb63" }
gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "b05f40c5546b47bcf9561136dc0fcdcd9968cb63", features = ["font-kit"] }

[workspace]
use gpui::{
    div, list, prelude::*, px, rgb, size, App, Bounds, Context, Div, FontWeight, ListAlignment,
    ListState, TitlebarOptions, Window, WindowBounds, WindowOptions,
};
use gpui_platform::application;

const COLUMN: f32 = 560.0;
const PADDING: f32 = 20.0;
const ROWS: usize = 12;

const PARAGRAPH: &str = "the quick brown fox jumps over the lazy dog while the \
    measure cache hands back a size that was shaped against a width this row \
    will never be laid out at, which is how the rows below end up too short";

fn bullet_row(text: String) -> Div {
    div()
        .flex()
        .items_start()
        .gap(px(8.0))
        .child(
            div()
                .mt(px(8.0))
                .w(px(16.0))
                .flex_none()
                .text_color(rgb(0x6b7280))
                .child("•"),
        )
        .child(
            div()
                .flex_1()
                .min_w(px(1.0))
                .flex()
                .flex_col()
                .child(div().mt(px(8.0)).child(text)),
        )
}

fn item_body(index: usize) -> Div {
    match index {
        0 => div().flex().flex_col().children(
            (0..ROWS).map(|row| bullet_row(format!("row {row:02} — {PARAGRAPH}"))),
        ),
        1 => div().mt(px(20.0)).child(
            div()
                .text_size(px(17.0))
                .font_weight(FontWeight::SEMIBOLD)
                .text_color(rgb(0xff5f56))
                .child("NEXT BLOCK — should start below every numbered row"),
        ),
        _ => bullet_row(String::from("after the heading — stacking continues here")),
    }
}

struct Repro {
    broken: ListState,
    fixed: ListState,
}

impl Render for Repro {
    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
        let pane = |label: &'static str, color: u32, body: Div| {
            div()
                .flex_1()
                .min_w(px(1.0))
                .h_full()
                .flex()
                .flex_col()
                .child(
                    div()
                        .flex_none()
                        .h(px(30.0))
                        .px(px(12.0))
                        .flex()
                        .items_center()
                        .bg(rgb(0x21242b))
                        .font_weight(FontWeight::SEMIBOLD)
                        .text_color(rgb(color))
                        .child(label),
                )
                .child(body.flex_1().min_h(px(1.0)))
        };
        // BROKEN: the width cap lives inside each item, under an auto width.
        let broken = list(self.broken.clone(), |index, _, _| {
            div()
                .w_full()
                .flex()
                .flex_col()
                .items_center()
                .child(
                    div()
                        .w_full()
                        .max_w(px(COLUMN))
                        .px(px(PADDING))
                        .child(item_body(index)),
                )
                .into_any_element()
        });
        // FIXED: identical content; the cap sits on the list element, whose
        // bounds are resolved before any item lays out.
        let fixed = list(self.fixed.clone(), |index, _, _| {
            div()
                .w_full()
                .px(px(PADDING))
                .child(item_body(index))
                .into_any_element()
        });
        div()
            .size_full()
            .bg(rgb(0x16181d))
            .text_color(rgb(0xc8ccd4))
            .text_size(px(12.5))
            .flex()
            .child(pane(
                "BROKEN — max_w inside each list item",
                0xff5f56,
                div().child(broken.size_full()),
            ))
            .child(div().flex_none().w(px(1.0)).h_full().bg(rgb(0x3a3f4b)))
            .child(pane(
                "FIXED — cap on the list element",
                0x27c93f,
                div().flex()
                    .justify_center()
                    .child(fixed.h_full().w_full().max_w(px(COLUMN))),
            ))
    }
}

fn main() {
    application().run(|cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(1400.0), px(900.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                titlebar: Some(TitlebarOptions {
                    title: Some("gpui-text-overlap-repro".into()),
                    ..Default::default()
                }),
                ..Default::default()
            },
            |_, cx| {
                cx.new(|_| Repro {
                    broken: ListState::new(3, ListAlignment::Top, px(400.0)),
                    fixed: ListState::new(3, ListAlignment::Top, px(400.0)),
                })
            },
        )
        .unwrap();
        cx.activate(true);
    });
}

There is also a 45-line pure-taffy repro (no gpui) in DioxusLabs/taffy#1095: fails on taffy 0.4.4–0.12.2, passes on 0.13.0.

What we verified

  • Not gpui's text element. The measure cache in elements/text.rs does answer min/max-content queries with sizes shaped at the wrong width (wrap_width.is_none() || wrap_width == cached), but disabling that cache entirely reproduces a bit-for-bit identical wrong frame. The stale height comes from taffy's flex sizing, not the text element.
  • Node-level taffy logs show the capped column content-measured at known.width = 700 (the parent's width; its own max_width is 560) → 12 two-line rows → height 581. The final pass wraps the text at 496 → 12 three-line rows → 824 painted. The item root keeps 581, gpui::list stacks the next items at 581, and ~240px of content is painted over.
  • The taffy clamp fixes the app with stock gpui: applying the Dropping frames when moving the cursor up and down #989 clamp to taffy 0.4.4 makes the broken pane render identically to the fixed one; the pure repro passes on stock taffy 0.13.0.
  • Spec and Chrome agree the clamp is required (flexbox §9.2 step 3 item E sizes the item at its used cross size; Chrome renders the analog DOM correctly).

Relation to previous reports

#30002 / #30097 ("Agent Panel: Overlapping text following mis-sized Code block") look like this exact failure — one commenter notes it appearing while wheel-scrolling, which is when a tall item's corrupted bottom boundary scrolls into view. #30377 fixed them by removing the triggering layout in active_thread.rs ("Seems that h_full was causing it to use the height of the overall list item for some reason") — the mechanism stayed, and any max_w-over-auto-width around wrapped text inside a list item still hits it on taffy ≤ 0.12.2.

Suggested action

Bump taffy to 0.13.0 (or cherry-pick the child_known_dimensions clamp from DioxusLabs/taffy#989). Until then, the app-side workaround for gpui users is to keep width caps on the list element itself — its bounds are resolved before items lay out as roots — and never between an item's root and wrapped text.

Environment

macOS 15 (Darwin 24.6.0), Apple Silicon. Verified at zed revs b05f40c5546b47bcf9561136dc0fcdcd9968cb63 (taffy 0.12.1) and d637307bd75389f63adbb7d86bc66174ee817d0d (taffy 0.4.4); zed main pins taffy 0.12.2 as of 2026-08-11.

Visual-aid

Image

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:gpuiGPUI rendering framework supportreach:all usersBug that happens for everyone on every platform no matter how they use Zedseverity:S3Papercuts, minor issues with a clear non-tedious workaround, cosmetic bugsstate:reproducibleVerified steps to reproduce included or someone on the team managed to reproduce

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions