fix(shell): debounce viewport resize so animations stop flashing

Sidebar / window-edge animations emit a new viewport bounds on every
GPUI paint. The previous flow fired `runtime.ensure_tab` synchronously
on every change, which reached down into Servo's surfman backend and
destroyed + reallocated the rendering framebuffer 50+ times per second
— each cycle leaving the NSView with an empty surface until Servo's
next paint completed. The user saw the page strobe blank-then-content
on every gesture, plus a one-time Metal driver warning about an
unloadable texture (the previous framebuffer caught mid-recreate).

Track `viewport_size_changed_at` on `PerTabSurface` and propagate the
debounce on two seams:

- `record_viewport_size` returns `Buffered` for a transition that
  arrives inside the 80 ms window of the previous one, so the
  synchronous `flush_external_web_surface_tick` from the GPUI paint
  callback skips the resize altogether. The very first measurement
  and the first transition after a quiet period still return `Applied`
  so a single drag step or page-load is not delayed.
- `ensure_surface` skips while the viewport is settling, but only
  *after* the initial ensure has installed `last_ensure_key`. The
  first ensure must fire even mid-gesture or the page never loads.
- `next_tick_delay` clamps the poll cadence to `ACTIVE_POLL_INTERVAL`
  while any visible tab is settling, so the trailing-edge resize
  fires within a frame of the gesture stopping instead of waiting
  for an 80 ms idle tick.

The synchronous `record + ensure` pattern in
`failed_surface_ensure_waits_for_a_new_key_before_retrying` can't
advance an `Instant`, so it now calls a `#[cfg(test)]`
`clear_viewport_resize_debounce_for_test` to simulate the
trailing edge and exercise the retry-on-new-key business rule in
isolation. Adds `rapid_viewport_changes_buffer_until_gesture_settles`
to lock the new behaviour.
This commit is contained in:
2026-05-18 14:53:56 -04:00
parent fc1c0ed8d3
commit 8aa9ddaeb2
5 changed files with 143 additions and 3 deletions
@@ -126,6 +126,13 @@ pub(super) struct WebSurfaceTickResult {
pub(super) struct PerTabSurface {
pub(super) viewport_bounds: Option<Bounds<Pixels>>,
pub(super) viewport_size: Option<WebSurfaceSize>,
/// When [`viewport_size`] last *transitioned* to a new value. The
/// initial measurement does not update this; only subsequent
/// genuine size changes do. Drives [`viewport_size_is_settling`]
/// so a sidebar / window animation that emits a viewport bounds on
/// every GPUI paint does not translate into a Servo framebuffer
/// destroy/recreate every frame (the "page flashing" symptom).
viewport_size_changed_at: Option<Instant>,
pub(super) native_surface: Option<NativeSurfaceHandle>,
pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>,
pub(super) hover_point: Option<WebSurfaceClickPoint>,
@@ -146,6 +153,7 @@ impl PerTabSurface {
Self {
viewport_bounds: None,
viewport_size: None,
viewport_size_changed_at: None,
native_surface: None,
last_ensure_key: None,
hover_point: None,
@@ -188,6 +196,37 @@ impl PerTabSurface {
self.last_ensure_key.as_ref() != Some(key) || self.has_pending_input()
}
/// True when the viewport bounds have changed within the last
/// [`VIEWPORT_RESIZE_DEBOUNCE`] window. The renderer treats this
/// as "the user is mid-animation" and holds off telling Servo to
/// resize its rendering context until the gesture settles —
/// Servo's surfman backend recreates the framebuffer on every
/// `rendering_context.resize`, which would otherwise flash a
/// blank surface for every animation frame.
pub(super) fn viewport_size_is_settling(&self, now: Instant) -> bool {
self.viewport_size_changed_at
.is_some_and(|last| now.duration_since(last) < VIEWPORT_RESIZE_DEBOUNCE)
}
/// Stamp the moment a genuine viewport size transition happened.
/// Only called when [`viewport_size`] is actually moving to a new
/// value — the very first measurement does *not* invoke this so
/// `viewport_size_is_settling` stays false at page-load time and
/// the initial `ensure_surface` is allowed to fire immediately.
pub(super) fn mark_viewport_size_changed(&mut self, now: Instant) {
self.viewport_size_changed_at = Some(now);
}
/// Test-only escape hatch: simulate the trailing edge of a resize
/// animation by clearing the debounce timestamp, so a synchronous
/// `record + ensure` sequence in tests behaves as if the gesture
/// has fully settled. Production code drives this via the poll
/// cadence and time elapsing — tests can't advance an `Instant`.
#[cfg(test)]
pub(super) fn clear_viewport_resize_debounce_for_test(&mut self) {
self.viewport_size_changed_at = None;
}
pub(super) fn mark_ensured(&mut self, key: WebSurfaceEnsureKey) {
self.last_ensure_key = Some(key);
}
@@ -226,6 +265,12 @@ impl PerTabSurface {
const HOVER_INPUT_MIN_INTERVAL: Duration = Duration::from_millis(32);
/// How long the viewport must hold its new size before we propagate
/// the resize down to Servo. 80 ms is short enough that a user-driven
/// drag still feels live, and long enough to collapse a 60 Hz sidebar
/// animation (~17 ms per frame) into a single trailing-edge resize.
const VIEWPORT_RESIZE_DEBOUNCE: Duration = Duration::from_millis(80);
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct WebSurfaceEnsureKey {
requested_url: String,