[REGRESSION] macOS Space switch destroys immediate viewports, resetting their position to screen center
- Dominant language
- Rust
- Stars
- 30.6k
- Forks
- 2.1k
- Avg merge
- 1d 9h
- Merged PRs (30d)
- 72
Description
**Platform:** macOS (confirmed on macOS 26, Apple Silicon)
**eframe version:** 0.34.1
**egui version:** 0.34.1
**Backend:** glow (not tested with wgpu)
**Related:** #8043 (different macOS viewport regression in 0.34)
---
## Describe the bug
When a macOS user switches to a different Space (virtual desktop) and then switches back, all child windows opened via `show_viewport_immediate` lose their position and reappear at the screen center.
This happens whenever macOS reports the root window as no longer visible, which covers two distinct gestures:
- **Space switch** (`Ctrl+←/→` or 3-finger swipe): macOS sends `WindowEvent::Occluded(true)`.
- **Minimize to Dock** (yellow traffic-light button): macOS sends `WindowEvent::Minimized(true)`.
Both cause `ViewportInfo::visible()` to return `false`, which is the common trigger. In both cases the child window disappears while the root is hidden, and reappears centered when the root is restored.
> **Note:** Simply backgrounding the app (clicking another app or `Cmd+Tab`) does **not** trigger this bug, macOS sends `Focused(false)` in that case but the root window remains visible (`Occluded` and `Minimized` stay false).
## Root cause
In `eframe/src/native/epi_integration.rs`, the `update()` function receives an `is_visible` flag derived from `ViewportInfo::visible()`, which returns `false` when the window is occluded or minimized.
When `is_visible = false`, **`app.update()` is skipped entirely**:
```rust
// Current upstream code (eframe 0.34.1)
if is_visible {
app.update(ui.ctx(), &mut self.frame); // <— skipped when occluded
app.ui(ui, &mut self.frame);
}
```
Because `app.update()` is not called, no `show_viewport_immediate(...)` calls are made. egui then garbage-collects any viewport that wasn't marked `used = true` that frame, destroying the native OS windows. When the user returns to the Space, the viewports are **recreated from scratch**, without any saved position, so the OS places them at the screen center.
## Steps to reproduce
1. Run the minimal reproduction below on macOS.
2. Click **"Open child window"** and move the child window to a corner of the screen.
3. Switch to a different macOS Space (e.g. with a 3-finger swipe or `Ctrl+←`).
4. Switch back to the original Space.
**Expected:** The child window is still at the corner where you left it.
**Actual:** The child window reappears at the screen center.
## Minimal reproduction
```rust
// Cargo.toml:
// [dependencies]
// eframe = "0.34.1"
// egui = "0.34.1"
use eframe::egui;
fn main() -> eframe::Result {
eframe::run_native(
"Viewport Position Bug",
eframe::NativeOptions::default(),
Box::new(|_cc| Ok(Box::new(App::default()))),
)
}
#[derive(Default)]
struct App {
child_open: bool,
}
impl eframe::App for App {
#[allow(deprecated)]
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.label("1. Open the child window below.");
ui.label("2. Move it somewhere (e.g. a corner).");
ui.label("3. Switch to another macOS Space and come back.");
ui.label("-> The child window will have reset to screen center.");
ui.separator();
ui.checkbox(&mut self.child_open, "Open child window");
});
if self.child_open {
let vp_id = egui::ViewportId::from_hash_of("child_viewport");
ctx.show_viewport_immediate(
vp_id,
egui::ViewportBuilder::default()
.with_title("Child Window — move me!")
.with_inner_size([300.0, 150.0]),
|vctx, _class| {
if vctx.input(|i| i.viewport().close_requested()) {
vctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
egui::CentralPanel::default().show(vctx, |ui| {
ui.label("I am a child viewport.");
ui.label("Switch Space and come back:");
ui.label("I will have moved to screen center.");
});
},
);
}
}
// Required by eframe 0.34 trait
fn ui(&mut self, _ui: &mut egui::Ui, _frame: &mut eframe::Frame) {}
}
```
## Proposed fix
Run `app.update()` unconditionally, keeping only `app.ui()` (the painting step) behind the `is_visible` guard. This way, the logical tree of viewports is maintained even while the window is occluded, and the OS preserves their positions natively.
```diff
--- a/crates/eframe/src/native/epi_integration.rs
+++ b/crates/eframe/src/native/epi_integration.rs
@@ -283,15 +283,15 @@ impl EpiIntegration {
app.logic(ui.ctx(), &mut self.frame);
}
- if is_visible {
- {
- profiling::scope!("App::update");
- #[expect(deprecated)]
- app.update(ui.ctx(), &mut self.frame);
- }
-
- {
- profiling::scope!("App::ui");
- app.ui(ui, &mut self.frame);
- }
+ {
+ profiling::scope!("App::update");
+ #[expect(deprecated)]
+ app.update(ui.ctx(), &mut self.frame);
}
+
+ if is_visible {
+ profiling::scope!("App::ui");
+ app.ui(ui, &mut self.frame);
+ }
}
});
```
The same fix should be applied symmetrically to `wgpu_integration.rs` if the same pattern exists there.
## Notes
- `fn logic()` already runs unconditionally, the same should apply to `update()` for viewport lifecycle purposes.
- The rendering path stays correctly gated on `is_visible` in `glow_integration.rs` / `wgpu_integration.rs`, so this does not cause unnecessary GPU work.
- This regression appears to have been introduced alongside PR #7950 (0.34.0).
- Workaround: `ctx.set_embed_viewports(true)` avoids the issue but forces all child viewports to be embedded windows (major UX change).
Contributor guide
Research direction
Start in eframe/src/native/epi_integration.rs at EpiIntegration::update and compare the visibility guards around app.update() and app.ui(). Check wgpu_integration.rs for the same pattern, then run the macOS reproduction with glow and verify that child viewports retain their positions after a Space switch or minimize/restore; the fix is complete when both native paths preserve those windows without painting while hidden.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- desktop
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 70/100