Repository navigation
gpui-fast: view under a prepaint_as_root child (e.g. uniform_list) panics "measurement has not been performed" when spliced #3408
Description
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, featuregpui-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)
- Only the nested
Statusview is notified, sorequest_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. splice_prepaint(src/fast/splice.rs:541) reuses the previous frame's prepaint around the view. The ancestor'sprepaint, which would callprepaint_as_rootand socompute_layoutfor that root, never runs, and the window-level layout pass doesn't reach that root either.- The rebuilt view is then prepainted at
layout_bounds(gap.layout_id)with its text never measured, andTextLayout::prepaintunwraps 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
dtateks commentedon Oct 8, 2026
This also crashes a real app in normal use, not only in tests: in our app (Zent, gpui-kit
main@ 1af2d1f withgpui-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/Interactivityframes omitted):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_rootplaced, and its changed text is never measured before prepaint. The panic aborts the app because our release profile usespanic = "abort".gpui: Fix layout panics and stale rendering in retained view splices (#…
huacnlee commentedon Oct 8, 2026
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_rootplacer,uniform_list, variable-heightlist, andcontainer_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=0workaround should no longer be needed for this issue.dtateks commentedon Oct 8, 2026
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.0taffy_tree.rs:828). It's reached fromTaffyLayoutEngine::layout_reaches(src/fast/layout.rs:492) viaWindow::splice_layout(src/fast/splice.rs:345). In our app the frame loop reaches it with ordinary notifies, and withpanic = "abort"that aborts the process.Where it seems to go wrong (0.1.5):
divappears 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.retained.rs:1577-1586→reuse_retained_prepaint). That copies last frame's record, includinglayout: record.layout.clone()(retained.rs:755), so the record still holds the old rootLayoutId. This frame's request claims a new node under the new key, nothing claims the old one, andrelease_unclaimed_nodesfrees it (fast/layout.rs:897-907).splice_layoutpasses the record's freed root tolayout_reaches(splice.rs:345), andtaffy.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.tomlas above, gpui-kitmain@ 02daace, gpui-fast 0.1.5):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.
gpui: fix stale cached layout nodes after element path changes (#52)
gpui: fix stale cached layout nodes after element path changes (zed-i…
huacnlee commentedon Oct 12, 2026
Close by longbridge/gpui-fast#52