1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 22:53:14 -04:00

Call logic even while browser tab is in background (#8257)

* Closes https://github.com/emilk/egui/issues/5112

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Meurer
2026-06-25 14:59:45 +02:00
committed by GitHub
parent 26ead4af21
commit 2e26b70ae9
10 changed files with 277 additions and 14 deletions

View File

@@ -113,12 +113,32 @@ fn install_blur_focus(runner_ref: &WebRunner, target: &EventTarget) -> Result<()
// NOTE: because of the text agent we sometime miss 'blur' events,
// so we also poll the focus state each frame in `AppRunner::logic`.
for event_name in ["blur", "focus", "visibilitychange"] {
let closure = move |_event: web_sys::MouseEvent, runner: &mut AppRunner| {
log::trace!("{} {event_name:?}", runner.canvas().id());
runner.update_focus();
};
let closure =
move |_event: web_sys::MouseEvent, runner: &mut AppRunner, web_runner: &WebRunner| {
log::trace!("{} {event_name:?}", runner.canvas().id());
runner.update_focus();
runner_ref.add_event_listener(target, event_name, closure)?;
if event_name == "visibilitychange" {
// The tab was hidden or shown. An in-flight `requestAnimationFrame` is paused
// while hidden, so reschedule the paint loop using the scheduling mechanism
// (`setTimeout` vs `requestAnimationFrame`) appropriate for the new state.
// This keeps `App::update` running while hidden, and switches back to smooth
// animation frames once visible again.
if let Err(err) = web_runner.reschedule_frame() {
log::error!(
"Failed to reschedule frame on visibility change: {}",
super::string_from_js_value(&err)
);
}
}
};
runner_ref.add_event_listener_ex(
target,
event_name,
&web_sys::AddEventListenerOptions::default(),
closure,
)?;
}
Ok(())
}

View File

@@ -129,8 +129,7 @@ impl WebRunner {
self.unsubscribe_from_all_events();
if let Some(frame) = self.frame.take() {
let window = web_sys::window().unwrap();
window.cancel_animation_frame(frame.id).ok();
frame.cancel(&web_sys::window().unwrap());
}
if let Some(runner) = self.app_runner.replace(None) {
@@ -234,6 +233,12 @@ impl WebRunner {
///
/// It is safe to call `request_animation_frame` multiple times in quick succession,
/// this function guarantees that only one animation frame is scheduled at a time.
///
/// A hidden browser tab (e.g. a backgrounded tab) does not receive
/// `requestAnimationFrame` callbacks, which would otherwise stop our paint loop
/// and prevent `App::update` from running in response to `request_repaint`.
/// To keep running in that case, we fall back to `setTimeout`, which keeps firing
/// while hidden (the browser throttles it to roughly once per second).
pub(crate) fn request_animation_frame(&self) -> Result<(), wasm_bindgen::JsValue> {
if self.frame.borrow().is_some() {
// there is already an animation frame in flight
@@ -252,14 +257,47 @@ impl WebRunner {
}
});
let id = window.request_animation_frame(closure.as_ref().unchecked_ref())?;
self.frame.borrow_mut().replace(AnimationFrameRequest {
id,
_closure: closure,
});
let hidden = window.document().is_some_and(|document| document.hidden());
let request = if hidden {
// The tab is hidden: `requestAnimationFrame` would not fire, so use a timer instead.
let id = window.set_timeout_with_callback_and_timeout_and_arguments_0(
closure.as_ref().unchecked_ref(),
// Browsers clamp background timers to ~1s, so the exact value has little effect
// while hidden, but keeps us responsive right after the tab is hidden:
10,
)?;
AnimationFrameRequest {
id,
kind: AnimationFrameKind::Timeout,
_closure: closure,
}
} else {
let id = window.request_animation_frame(closure.as_ref().unchecked_ref())?;
AnimationFrameRequest {
id,
kind: AnimationFrameKind::AnimationFrame,
_closure: closure,
}
};
self.frame.borrow_mut().replace(request);
Ok(())
}
/// Cancel any in-flight frame request and schedule a fresh one.
///
/// Called on `visibilitychange` so we switch between `requestAnimationFrame` (visible)
/// and `setTimeout` (hidden) scheduling. This is necessary because an in-flight
/// `requestAnimationFrame` is paused while the tab is hidden, which would otherwise
/// stall the paint loop and stop `App::update` from running while hidden.
pub(crate) fn reschedule_frame(&self) -> Result<(), wasm_bindgen::JsValue> {
if let Some(frame) = self.frame.borrow_mut().take() {
frame.cancel(&web_sys::window().unwrap());
}
self.request_animation_frame()
}
}
// ----------------------------------------------------------------------------
@@ -269,11 +307,37 @@ struct AnimationFrameRequest {
/// Represents the ID of a frame in flight.
id: i32,
/// How the frame was scheduled, so we know how to cancel it.
kind: AnimationFrameKind,
/// The callback given to `request_animation_frame`, stored here both to prevent it
/// from being canceled, and from having to `.forget()` it.
_closure: Closure<dyn FnMut() -> Result<(), JsValue>>,
}
impl AnimationFrameRequest {
/// Cancel the in-flight frame request, using the API matching how it was scheduled.
fn cancel(&self, window: &web_sys::Window) {
match self.kind {
AnimationFrameKind::AnimationFrame => {
window.cancel_animation_frame(self.id).ok();
}
AnimationFrameKind::Timeout => {
window.clear_timeout_with_handle(self.id);
}
}
}
}
/// How an [`AnimationFrameRequest`] was scheduled.
enum AnimationFrameKind {
/// Scheduled with `requestAnimationFrame` (visible tab).
AnimationFrame,
/// Scheduled with `setTimeout` (hidden tab).
Timeout,
}
struct TargetEvent {
target: web_sys::EventTarget,
event_name: String,