Skip to content

[Windows] OnEmptyFrameGenerated resizes the render surface with transposed width/height, corrupting rendering until the next resize #187922

Description

@palmoni5

Steps to reproduce

This is a code-level bug in the Windows embedder's resize synchronization, reproducible whenever an empty frame is generated while a window resize is pending (ResizeState::kResizeStarted). The most common real-world trigger is the (very popular) hidden-window startup choreography:

  1. Create the top-level window hidden (e.g. the window_manager package's waitUntilReadyToShow flow, or a custom runner that shows the window only after first content).
  2. While the window is hidden and the Flutter scene is still empty/transparent (e.g. a splash phase), resize the window (SetWindowPos / windowManager.setBounds / setTitleBarStyle, which changes the client area via WM_NCCALCSIZE).
  3. If the engine generates an empty frame during the 100 ms resize-synchronization window, the render surface is resized to transposed dimensions (height ↔ width).
  4. Show the window. All subsequent (correctly sized) frames are rasterized into the transposed surface, so the window displays horizontally stretched content with a black band — persisting until the next window resize.

Because it depends on an empty frame landing inside the ~100 ms kResizeStarted window, the failure is intermittent — in our production app (Otzaria) it reproduced on ~1 out of 10 cold launches, on Windows 11, single monitor, 100 % scaling (no DPI changes involved).

The bug

engine/src/flutter/shell/platform/windows/flutter_windows_view.cc, in OnEmptyFrameGenerated() — present in 3.44.0 stable and current master:

https://github.com/flutter/flutter/blob/master/engine/src/flutter/shell/platform/windows/flutter_windows_view.cc#L155

bool FlutterWindowsView::OnEmptyFrameGenerated() {
  std::unique_lock<std::mutex> lock(resize_mutex_);
  ...
  if (resize_status_ != ResizeState::kResizeStarted) {
    return true;
  }
  if (!ResizeRenderSurface(resize_target_height_, resize_target_width_)) {  // <-- arguments transposed
    return false;
  }
  resize_status_ = ResizeState::kFrameGenerated;
  return true;
}

Compare with the correct call in OnFrameGenerated() a few lines below (line 190):

  if (!ResizeRenderSurface(resize_target_width_, resize_target_height_)) {  // correct order

ResizeRenderSurface is declared as (size_t width, size_t height), so the empty-frame path resizes the surface to height × width instead of width × height.

Why the corruption persists

After the transposed resize, resize_status_ advances to kFrameGeneratedkDone. From that point on, OnFrameGenerated() takes the early-return path (resize_status_ != kResizeStarted → return true) without ever validating or fixing the surface size, so every subsequent correctly-sized frame is drawn into the transposed surface. The only thing that heals it is the next WM_SIZE (e.g. the user manually resizing the window), which starts a fresh resize cycle.

Visual signature (how we confirmed the root cause)

With a transposed surface, the swapchain buffer is h × w inside a w × h client area. DWM stretches it to the window, producing:

  • a horizontal stretch factor of w / h (≈ 1.78 for 16:9), and
  • a black band whose height is (w − h) / w of the window height (≈ 43.7 % for 16:9).

We measured screenshots of the corrupted state on two unrelated machines (different resolutions, 100 % and 125 % scaling): the black band is 44 % of the window height on both, matching the prediction exactly.

state screenshot measurement predicted by transposition
1264×711 client (16:9-ish) black band ≈ 44 % of height, content stretched horizontally 43.7 %, ×1.78

Expected results

The empty-frame path resizes the render surface to the resize target — ResizeRenderSurface(resize_target_width_, resize_target_height_) — identical to the non-empty-frame path.

Actual results

The surface is resized to transposed dimensions and stays transposed until the next window resize; the app appears "zoomed"/stretched with a large black band.

Proposed fix

One-line change — swap the arguments at flutter_windows_view.cc:155 to match OnFrameGenerated():

  if (!ResizeRenderSurface(resize_target_width_, resize_target_height_)) {

Workaround for affected apps

In the runner, immediately before revealing the hidden window, jiggle the Flutter child HWND by 1 px and back (two MoveWindow calls). This forces a fresh resize cycle that restores the correct surface size. Verified across 51 cold launches with zero recurrences (vs. ~1/10 before).

Flutter Doctor output

flutter --version
Flutter 3.44.0 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 559ffa3f75 (4 weeks ago) • 2026-05-15 14:13:13 -0700
Engine • hash fcf463a2242790d1fdcd9d044f533080f5022e18 (revision 4c525dac5e) (27 days ago) • 2026-05-15 19:00:04.000Z
Tools • Dart 3.12.0 • DevTools 2.57.0

OS: Windows 11 Pro 10.0.26200, x64. Reproduced on two machines (100 % and 125 % display scaling, single monitor).

Metadata

Metadata

Assignees

No one assigned

    Labels

    a: desktopRunning on desktopc: renderingUI glitches reported at the engine/skia or impeller rendering levelengineflutter/engine related. See also e: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-windowsBuilding on or for Windows specificallyteam-windowsOwned by the Windows platform team

    Type

    No type
    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