Skip to content

gpui-fast: view under a prepaint_as_root child (e.g. uniform_list) panics "measurement has not been performed" when spliced #3408

Closed

Description

@dtateks

Description

With the gpui-fast feature (view retention on, the default), a view nested under an element that places its child with AnyElement::prepaint_as_root panics when it re-renders on its own cx.notify() with new text:

panicked at gpui-fast-0.1.4/src/elements/text.rs:650:14:
called `Result::unwrap()` on an `Err` value: measurement has not been performed on Session unavailable: a longer status

uniform_list places its items this way, so a view inside a uniform_list item hits it too. The same tests pass with GPUI_VIEW_RETENTION=0, and they pass when the view is in normal flow.

(Filed here because issues are disabled on longbridge/gpui-fast.)

Environment

  • gpui-kit: main @ 1af2d1f, feature gpui-fast
  • gpui-fast: 0.1.4 (crates.io)
  • Platform: macOS 27.0.1, rustc 1.99.0
  • Headless TestAppContext

Steps to Reproduce

Cargo.toml:

[package]
name = "gpui-fast-splice-repro"
version = "0.1.0"
edition = "2024"

[dependencies]
gpui-kit = { git = "https://github.com/longbridge/gpui-kit", features = ["gpui-fast"] }

[dev-dependencies]
gpui-kit = { git = "https://github.com/longbridge/gpui-kit", features = ["gpui-fast", "test-support"] }

src/lib.rs:

#![cfg(test)]
use core::prelude::v1::test;
use gpui_kit::*;

/// Places its child as a layout root in its own prepaint, as list-like and
/// arithmetic layout elements do.
struct RootPlacer {
    child: AnyElement,
}

impl IntoElement for RootPlacer {
    type Element = Self;
    fn into_element(self) -> Self {
        self
    }
}

impl Element for RootPlacer {
    type RequestLayoutState = ();
    type PrepaintState = ();
    fn id(&self) -> Option<ElementId> {
        None
    }
    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
        None
    }
    fn request_layout(
        &mut self,
        _: Option<&GlobalElementId>,
        _: Option<&InspectorElementId>,
        window: &mut Window,
        cx: &mut App,
    ) -> (LayoutId, ()) {
        let mut style = Style::default();
        style.size.width = relative(1.).into();
        style.size.height = relative(1.).into();
        (window.request_layout(style, None, cx), ())
    }
    fn prepaint(
        &mut self,
        _: Option<&GlobalElementId>,
        _: Option<&InspectorElementId>,
        bounds: Bounds<Pixels>,
        _: &mut (),
        window: &mut Window,
        cx: &mut App,
    ) {
        self.child
            .prepaint_as_root(bounds.origin, bounds.size.into(), window, cx);
    }
    fn paint(
        &mut self,
        _: Option<&GlobalElementId>,
        _: Option<&InspectorElementId>,
        _: Bounds<Pixels>,
        _: &mut (),
        _: &mut (),
        window: &mut Window,
        cx: &mut App,
    ) {
        self.child.paint(window, cx);
    }
}

struct Status {
    text: SharedString,
}

impl Render for Status {
    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
        div().size_full().flex().items_center().justify_center().child(self.text.clone())
    }
}

struct Host {
    status: AnyView,
    root_placed: bool,
    in_list: bool,
}

impl Render for Host {
    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
        let child = div().size_full().child(self.status.clone()).into_any_element();
        let status = self.status.clone();
        let body = if self.in_list {
            uniform_list("probe-list", 1, move |_, _, _| {
                vec![div().h(px(40.)).child(status.clone()).into_any_element()]
            })
            .size_full()
            .into_any_element()
        } else if self.root_placed {
            RootPlacer { child }.into_any_element()
        } else {
            child
        };
        div().size_full().flex().flex_col().child("header").child(div().flex_1().child(body))
    }
}

fn status_change(cx: &mut TestAppContext, root_placed: bool, in_list: bool) {
    let (host, visual) = cx.add_window_view(|_, cx| {
        let status = cx.new(|_| Status { text: "Starting…".into() });
        Host { status: status.into(), root_placed, in_list }
    });
    visual.run_until_parked();
    let status = visual
        .read(|cx| host.read(cx).status.clone())
        .downcast::<Status>()
        .unwrap();
    visual.update(|_, cx| {
        status.update(cx, |status, cx| {
            status.text = "Session unavailable: a longer status".into();
            cx.notify();
        })
    });
    visual.run_until_parked();
}

#[gpui::test]
fn probe_status_change_in_flow(cx: &mut TestAppContext) {
    status_change(cx, false, false);
}

#[gpui::test]
fn probe_status_change_under_root_placed_child(cx: &mut TestAppContext) {
    status_change(cx, true, false);
}

#[gpui::test]
fn probe_status_change_in_uniform_list(cx: &mut TestAppContext) {
    status_change(cx, false, true);
}

Run cargo test, then GPUI_VIEW_RETENTION=0 cargo test.

Expected

All three tests pass with view retention on, as they do with GPUI_VIEW_RETENTION=0.

Actual

test probe_status_change_in_flow ... ok
test probe_status_change_under_root_placed_child ... FAILED
test probe_status_change_in_uniform_list ... FAILED

Both failures are the text.rs:650 panic above. With GPUI_VIEW_RETENTION=0, all 3 pass.

Where it seems to go wrong (gpui-fast 0.1.4)

  1. Only the nested Status view is notified, so request_view_layout_inner (src/fast/retained.rs:1335) takes the splice path. splice_layout (src/fast/splice.rs:292) rebuilds just that view and requests a new layout node for the changed text.
  2. splice_prepaint (src/fast/splice.rs:541) reuses the previous frame's prepaint around the view. The ancestor's prepaint, which would call prepaint_as_root and so compute_layout for that root, never runs, and the window-level layout pass doesn't reach that root either.
  3. The rebuilt view is then prepainted at layout_bounds(gap.layout_id) with its text never measured, and TextLayout::prepaint unwraps the missing measurement.

Possible directions: don't splice a view whose nearest layout root is placed by prepaint_as_root (fall back to the full path), or compute layout for that root before prepainting the spliced gap.

Activity

  1. dtateks commented on Oct 8, 2026

    @dtateks
    Author

    This also crashes a real app in normal use, not only in tests: in our app (Zent, gpui-kit main @ 1af2d1f with gpui-fast, gpui-fast 0.1.4, release build, macOS 27.0.1), a single click into a text editor view aborts the process. It happened twice in a row.

    The crashed main-thread stack from both macOS crash reports is identical (demangled, div/Interactivity frames omitted):

    core::result::unwrap_failed
    gpui::elements::text::TextLayout::prepaint
    <Drawable<SharedString> as ElementObject>::prepaint            (text inside the editor view)
    <Drawable<ViewElement<Entity<NativeEditorView>>> as ElementObject>::prepaint
    gpui::fast::retained::<ViewElement<AnyView>>::prepaint_view_inner
    gpui::fast::splice::<Window>::splice_prepaint
    <Drawable<ViewElement<Entity<WorkspaceView>>> as ElementObject>::prepaint
    gpui::element::AnyElement::prepaint_as_root
    <Drawable<LayoutRoot> as ElementObject>::prepaint               (our element; places its child with prepaint_as_root)
    gpui::fast::retained::<ViewElement<AnyView>>::prepaint_view_inner
    gpui::fast::splice::<Window>::splice_prepaint
    <Drawable<ViewElement<AnyView>> as ElementObject>::prepaint
    <Drawable<ViewElement<WindowBorder>> as ElementObject>::prepaint
    gpui::element::AnyElement::prepaint_as_root
    gpui::window::Window::draw_roots
    gpui::window::Window::draw
    gpui_macos::window::step
    

    It's the same path as the repro above: the click notifies only the editor view, the editor view is spliced under a root that prepaint_as_root placed, and its changed text is never measured before prepaint. The panic aborts the app because our release profile uses panic = "abort".

  2. huacnlee commented on Oct 8, 2026

    @huacnlee
    Member

    Fixed in longbridge/gpui-fast#46 and published in gpui-fast v0.1.5.

    Thanks for the detailed repro — we reproduced the panic and confirmed your diagnosis. Splicing now falls back to rebuilding the ancestor when an updated child is outside its Taffy layout tree, so the ancestor's prepaint computes the independent layout root before the child's text is prepainted.

    Added regression tests for the custom prepaint_as_root placer, uniform_list, variable-height list, and container_query, including nested splices and repeated text updates with retention enabled. They pass with the fix, and their output matches a full render with retention disabled.

    Please update to gpui-fast 0.1.5 and let us know if the crash persists in your app. The GPUI_VIEW_RETENTION=0 workaround should no longer be needed for this issue.

  3. dtateks commented on Oct 8, 2026

    @dtateks
    Author

    Thanks! gpui-fast 0.1.5 fixes the "measurement has not been performed" panic for us: our suite has none left.

    But the new splice path hits another panic, invalid SlotMap key used (taffy-0.13.0 taffy_tree.rs:828). It's reached from TaffyLayoutEngine::layout_reaches (src/fast/layout.rs:492) via Window::splice_layout (src/fast/splice.rs:345). In our app the frame loop reaches it with ordinary notifies, and with panic = "abort" that aborts the process.

    Where it seems to go wrong (0.1.5):

    1. In frame 2, an ancestor view re-renders and its element tree changes shape, for example an anonymous wrapper div appears or disappears above an unchanged .cached(...) child view. The child's element id stays the same, but its layout key is hashed from its path (fast/layout_key.rs:197), so the key changes.
    2. The cached child takes the reuse path (retained.rs:1577-1586 → reuse_retained_prepaint). That copies last frame's record, including layout: record.layout.clone() (retained.rs:755), so the record still holds the old root LayoutId. This frame's request claims a new node under the new key, nothing claims the old one, and release_unclaimed_nodes frees it (fast/layout.rs:897-907).
    3. In frame 3, only the child is notified, so the ancestor is spliced. splice_layout passes the record's freed root to layout_reaches (splice.rs:345), and taffy.parent(dead_node) panics.

    0.1.4 copied the record the same way but never looked the old root up in Taffy. layout_reaches, added with this fix, is the first code that does.

    Repro (same Cargo.toml as above, gpui-kit main @ 02daace, gpui-fast 0.1.5):

    #![cfg(test)]
    use core::prelude::v1::test;
    use gpui_kit::*;
    
    struct Frame;
    
    impl Render for Frame {
        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
            div().size_full().child("frame")
        }
    }
    
    struct Workspace {
        frame: Entity<Frame>,
        wrapped: bool,
    }
    
    impl Render for Workspace {
        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
            let frame = self
                .frame
                .clone()
                .cached(StyleRefinement::default().size_full())
                .into_any_element();
            // An anonymous wrapper changes the cached view's position in the
            // element tree, not its element id.
            let leaf = if self.wrapped {
                div().size_full().child(frame).into_any_element()
            } else {
                frame
            };
            div().size_full().child(leaf)
        }
    }
    
    struct Root {
        workspace: Entity<Workspace>,
    }
    
    impl Render for Root {
        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
            div().size_full().child(self.workspace.clone())
        }
    }
    
    fn draw(visual: &mut VisualTestContext) {
        visual.update(|window, cx| {
            window.simulate_next_frame(cx);
            window.draw(cx).clear(cx);
        });
    }
    
    fn run(cx: &mut TestAppContext, unwrap_between_frames: bool) {
        let frame = cx.new(|_| Frame);
        let workspace = cx.new(|_| Workspace {
            frame: frame.clone(),
            wrapped: true,
        });
        let (_root, visual) = cx.add_window_view(|_, _| Root {
            workspace: workspace.clone(),
        });
        draw(visual);
    
        // Frame 2: the workspace re-renders; the cached frame is clean and reused.
        // With `unwrap_between_frames` its wrapper goes away.
        workspace.update(visual, |workspace, cx| {
            workspace.wrapped = !unwrap_between_frames;
            cx.notify();
        });
        draw(visual);
    
        // Frame 3: only the cached frame changes, so the workspace is spliced
        // around it.
        frame.update(visual, |_, cx| cx.notify());
        draw(visual);
    }
    
    /// Panics on gpui-fast 0.1.5 with `invalid SlotMap key used`.
    #[gpui::test]
    fn splice_after_cached_view_moved(cx: &mut TestAppContext) {
        run(cx, true);
    }
    
    /// Contrast: the same frames with the wrapper kept.
    #[gpui::test]
    fn splice_after_cached_view_stayed(cx: &mut TestAppContext) {
        run(cx, false);
    }
    test splice_after_cached_view_stayed ... ok
    test splice_after_cached_view_moved ... FAILED
    panicked at taffy-0.13.0/src/tree/taffy_tree.rs:828:21:
    invalid SlotMap key used
       3: TaffyLayoutEngine::layout_reaches   gpui-fast-0.1.5/src/fast/layout.rs:492
       4: Window::splice_layout               gpui-fast-0.1.5/src/fast/splice.rs:345
    

    Both pass with GPUI_VIEW_RETENTION=0.

    Possible fixes: have the reuse path store this frame's layout in the copied record, or have the splice treat a root that no longer exists as a reason to rebuild the ancestor.

  4. huacnlee commented on Oct 12, 2026

    @huacnlee
    Member
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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions