Skip to content

position_popover runs before layout, causing one-frame positional flash on spawn #24272

Description

@TheTalkingPenguin

position_popover runs before layout, causing one-frame positional flash on spawn

Bevy version and features

  • Bevy 0.18.1
  • Feature: experimental_bevy_ui_widgets

Relevant system information

  • Windows 10
  • Rust stable (latest)

What you did

Spawned a Popover-bearing tooltip entity on hover using the canonical spawn/despawn pattern (matching the MenuPopup approach in examples/ui/standard_widgets.rs). The entity is spawned with Visibility::Hidden as documented by the inline comment // Will be visible after positioning in the official example.

fn on_pointer_over(
    trigger: On<Pointer<Over>>,
    mut commands: Commands,
    mut query: Query<(&mut BackgroundColor, &mut BorderColor)>,
    existing: Query<(), With<Tooltip>>,
) {
    if let Ok((mut bg, mut border)) = query.get_mut(trigger.entity) {
        *bg     = BackgroundColor(COLOR_HOVERED);
        *border = BorderColor::all(BORDER_HOVERED);
    }

    if !existing.is_empty() {
        return;
    }

    let tooltip = commands.spawn((
        Tooltip,
        Popover {
            positions: vec![
                PopoverPlacement {
                    side: PopoverSide::Bottom,
                    align: PopoverAlign::Center,
                    gap: 6.0,
                },
                PopoverPlacement {
                    side: PopoverSide::Top,
                    align: PopoverAlign::Center,
                    gap: 6.0,
                },
            ],
            window_margin: 8.0,
        },
        Node {
            padding: UiRect::all(px(6)),
            border:  UiRect::all(px(1)),
            ..default()
        },
        BackgroundColor(TOOLTIP_BG),
        BorderColor::all(TOOLTIP_BORDER),
        Visibility::Hidden, // Will be visible after positioning
        Pickable::IGNORE,
        children![(
            Text::new("Lorem ipsum dolor sit amet"),
            TextFont { font_size: 14.0, ..default() },
            TextColor(TOOLTIP_TEXT),
            Pickable::IGNORE,
        )],
    )).id();

    commands.entity(trigger.entity).add_child(tooltip);
}

fn on_pointer_out(
    trigger: On<Pointer<Out>>,
    mut commands: Commands,
    mut query: Query<(&mut BackgroundColor, &mut BorderColor)>,
    tooltips: Query<Entity, With<Tooltip>>,
) {
    if let Ok((mut bg, mut border)) = query.get_mut(trigger.entity) {
        *bg     = BackgroundColor(COLOR_NORMAL);
        *border = BorderColor::all(BORDER_NORMAL);
    }

    for entity in &tooltips {
        commands.entity(entity).despawn();
    }
}

What went wrong

Expected: The tooltip should appear at the correct centered position on the first frame, with Visibility::Hidden preventing any flash while Popover computes placement.

Actual: The tooltip visibly appears offset to the right for one frame, then snaps to the correct centered position on the next frame.

Root cause

position_popover is registered in PostUpdate at UiSystems::Prepare:

// crates/bevy_ui_widgets/src/popover.rs
impl Plugin for PopoverPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(PostUpdate, position_popover.in_set(UiSystems::Prepare));
    }
}

UiSystems::Prepare runs before UiSystems::Layout. On the frame a Popover entity is spawned, ComputedNode has not yet been computed by Taffy (it defaults to zero width/height). position_popover reads this zero-sized ComputedNode, centers a zero-width element (which lands to the right of the true center), and then sets Visibility::Visible:

// crates/bevy_ui_widgets/src/popover.rs, inside position_popover
visibility.set_if_neq(Visibility::Visible);

This overrides the spawned Visibility::Hidden before layout has run, so the entity becomes visible at the wrong position. On the next frame, ComputedNode has the correct size from the prior frame's layout pass, and Popover centers correctly.

The Visibility::Hidden at spawn is intended to prevent exactly this flash (as documented by the // Will be visible after positioning comment in examples/ui/standard_widgets.rs), but it cannot work when position_popover sets Visible before layout has computed the real size.

Proposed fix

Move position_popover to run after layout instead of before:

impl Plugin for PopoverPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(PostUpdate, position_popover.after(UiSystems::Layout));
    }
}

This way, on the spawn frame:

  1. Layout computes the correct ComputedNode size
  2. position_popover reads the correct size, computes the correct position, flips Visibility::Visible
  3. Render shows the tooltip at the correct position on the first frame

Additional information

  • This affects any widget using the spawn/despawn pattern with Popover, including MenuPopup from the official standard_widgets example (though the flash is less noticeable with menus because they are click-triggered rather than hover-triggered).
  • This is directly relevant to the planned Headless Tooltips feature (Headless Tooltips #20601), which proposes spawn-on-show / despawn-on-hide using Popover for placement. That design will hit this same first-frame flash unless position_popover scheduling is corrected.
  • Related: Standard widgets: Popover and Menu. #21636 (PR that added Popover and Menu), Headless Tooltips #20601 (Headless Tooltips design).

Workaround

Keep the tooltip entity always in layout (Display::Flex with the Popover component always present) so ComputedNode is always valid. Use a marker component and a PostUpdate system scheduled after UiSystems::Prepare to gate visibility, effectively overriding Popover's unconditional Visibility::Visible write for inactive tooltips. This eliminates the flash but requires fighting Popover's visibility behavior.

Metadata

Metadata

Assignees

No one assigned

    Labels

    C-BugAn unexpected or incorrect behaviorS-Needs-TriageThis issue needs to be labelled

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions