Sitelet https://github.com/flutter/flutter/pull/188772
Skip to content

[macOS] Resume app lifecycle on becomeActive to avoid frozen UI after occlusion - #188772

Merged
cbracken merged 9 commits into
flutter:masterfrom
ellie-ka-nam:pr-155977-macos-occlusion-resume
Aug 3, 2026
Merged

cbracken merged 9 commits into
flutter:masterfrom
ellie-ka-nam:pr-155977-macos-occlusion-resume

Conversation

@ellie-ka-nam

Copy link
Copy Markdown
Contributor

Fixes #155977

Problem

FlutterEngine drives the framework's AppLifecycleState from two inputs:

  • _active — toggled by applicationWillBecomeActive / applicationWillResignActive
  • _visible — toggled only by applicationDidChangeOcclusionState

handleWillBecomeActive resumed only when _visible was already true, otherwise it sent kHidden:

- (void)handleWillBecomeActive:(NSNotification*)notification {
  _active = YES;
  if (!_visible) {
    [self setApplicationState:flutter::AppLifecycleState::kHidden];
  } else {
    [self setApplicationState:flutter::AppLifecycleState::kResumed];
  }
}

macOS does not reliably deliver an NSApplicationDidChangeOcclusionState (visible) notification on every occlusion→visible transition. Returning to the app on the same screen (Cmd-Tab, Mission Control, clicking away and back) frequently delivers visible=NO when leaving but never delivers visible=YES when returning. When that happens:

  1. App is occluded → handleDidChangeOcclusionState sets _visible = NO, sends kHidden (framework disables frames — correct).
  2. App is brought back to the foreground → applicationWillBecomeActive fires, but _visible is still stale-NO, so handleWillBecomeActive sends kHidden again.
  3. The framework stays hidden: animations are muted, scheduleFrame is gated, no OnVsync is requested — the UI is frozen even though the window is fully on-screen and frontmost. It only recovers if the app is later occluded and revealed in a way that does fire the visible notification (e.g. a real Dock-icon activation).

Reproduces reliably on low-spec Intel Macs launched via Finder/LaunchServices (rarely on Apple Silicon).

Fix

An application receiving applicationWillBecomeActive is, by definition, frontmost and visible to the user, so treat becoming-active as an authoritative "visible" signal and resume. handleDidChangeOcclusionState still authoritatively drives kHidden when the app is genuinely occluded, so this does not prevent the app from going hidden — it only ensures that returning to the foreground always resumes, instead of depending on a notification macOS may never send.

Diagnosis

A local engine build with tracing in FlutterEngine (lifecycle) and FlutterVSyncWaiter/FlutterDisplayLink (vsync) showed, on the freezing return:

handleDidChangeOcclusionState visible=0   -> AppLifecycleState.hidden
handleWillBecomeActive   _visible(prev)=0   occlusionState&Visible=0   -> AppLifecycleState.hidden   (stuck)
(no further waitForVSync / onDisplayLink — frames were never requested again)

i.e. applicationWillBecomeActive does fire on the return, but [[NSApplication sharedApplication] occlusionState] still reports not-visible at that instant and the visible notification never arrives, so the old code re-sent kHidden. With the fix the same transition logs -> AppLifecycleState.resumed and frame production resumes immediately. The FlutterVSyncWaiter / FlutterDisplayLink layer was verified healthy — it was never the cause; the engine simply was never asked to render because the framework believed it was hidden.

Tests

Updated HandleLifecycleStates in FlutterEngineTest.mm: with the occlusion state reading not-visible, handleWillBecomeActive now expects kResumed (previously kHidden), directly exercising the missed-visible-notification scenario, and the following handleWillResignActive now expects kInactive.

Pre-launch checklist

  • I signed the [CLA].
  • I listed at least one issue that this PR fixes.
  • I added new tests to check the change I am making (updated the existing lifecycle test to cover the regression).
  • I updated/added relevant documentation (inline rationale comment).

… occlusion

handleWillBecomeActive only resumed when _visible was already true, but _visible
is driven solely by handleDidChangeOcclusionState. macOS does not reliably deliver
a DidChangeOcclusionState(visible) notification on every occlusion->visible
transition (e.g. returning to the app on the same screen via Cmd-Tab or Mission
Control), so _visible stayed stale-NO, returning to the foreground re-sent kHidden,
and the framework kept frames disabled and animations muted -- the UI appeared
frozen even though the window was on-screen and frontmost.

An application receiving applicationWillBecomeActive is frontmost and visible to
the user, so treat it as an authoritative visible signal and resume.
handleDidChangeOcclusionState still drives kHidden when the app is genuinely
occluded.

Fixes flutter#155977
@ellie-ka-nam
ellie-ka-nam requested a review from a team as a code owner June 30, 2026 07:25
@github-actions github-actions Bot added engine flutter/engine related. See also e: labels. platform-macos Building on or for macOS specifically a: desktop Running on desktop team-macos Owned by the macOS platform team labels Jun 30, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates FlutterEngine.mm to set _visible to YES and transition the application state to kResumed when handling the handleWillBecomeActive: notification. This prevents the UI from freezing due to missed macOS occlusion state notifications. The unit tests in FlutterEngineTest.mm have been updated to verify these state transitions. There are no review comments, and I have no additional feedback to provide.

@ellie-ka-nam

Copy link
Copy Markdown
Contributor Author

Friendly ping for review — this is a small, self-contained macOS embedder lifecycle fix (2 files, +22/-7) for #155977.

Root cause: on a same-screen return (Cmd-Tab / Mission Control) macOS doesn't reliably re-deliver the NSApplicationDidChangeOcclusionState visible notification, so _visible stays stale-NO and handleWillBecomeActive re-sends kHidden, leaving the framework with frames gated (UI frozen even though the window is frontmost). The fix treats willBecomeActive as an authoritative visible signal and resumes.

Verified on a low-spec Intel Mac with a local engine build: the freeze reproduces pre-fix from the 2nd return onward and is gone post-fix.

@gaaclarke @knopp — would either of you be able to take a look, or route this to the right macOS reviewer? Happy to adjust the test. Thanks! 🙏

@bdero bdero added the CICD Run CI/CD label Jul 4, 2026
@bdero

bdero commented Jul 4, 2026 •

Copy link
Copy Markdown
Member

I've been running into this issue almost daily on the macOS embedder for a month or so, and finally ended up diving in today after I managed to repro it 3 times within ~an hour.

TL;DR: I endorse this fix.

Validated this change against a live occurrence of #155977 on macOS 15.5 (Apple Silicon).

While the app was frozen with its window plainly visible on screen, I captured the relevant state via lldb.

NSWindow.isVisible            YES     (window ordered in)
NSWindow.occlusionState       0x2000  (visible bit clear)
NSApplication.occlusionState  0x2000  (visible bit clear)
FlutterEngine _visible        NO      (mirrors the OS state)

At the same moment, CGWindowListCopyWindowInfo from another process reported the same window as onscreen=true, layer=0, alpha=1.0. So the stale _visible is faithfully mirroring a stuck OS-side occlusion state, and no occlusion notification will ever arrive to correct it because AppKit believes nothing has changed... which I suppose is an AppKit bug. This also explains why minimize/restore recovers, re-ordering the window forces the occlusion state to be recomputed.

Re-querying occlusionState in handleWillBecomeActive would therefore read the same stale value, which makes treating becomeActive as an authoritative visibility signal the right approach rather than just a convenient one.

Emulating this change on the stuck process via the debugger, setting _visible = YES and invoking handleWillBecomeActive:, resumed rendering immediately with no other intervention.

@bdero bdero left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While here, the doc comment on handleDidChangeOcclusionState references applicationDidUnhide, the wrong notification.

// app is occluded and revealed again in a way that does fire the notification.
// Becoming active is itself an authoritative "visible" signal, so honor it here.
// https://github.com/flutter/flutter/issues/155977
_visible = YES;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unconditionally latching _visible = YES changes behavior when the app is activated while all of its windows are minimized (plain Cmd-Tab does not deminiaturize). That case previously stayed kHidden and now resumes, and since a minimized window produces no occlusion change, the app will keep rendering invisibly until it is deminiaturized.

Window ordering stays accurate even when occlusionState latches stale (isVisible read YES on the frozen-but-shown window in the capture above, and is NO for minimized windows), so gating on it fixes the freeze without that regression:

for (NSWindow* window in NSApp.windows) {
  if (window.isVisible) {
    _visible = YES;
    break;
  }
}

}
// An application that is becoming active is frontmost and visible to the user,
// so it should resume. `_visible` is only updated by handleDidChangeOcclusionState,
// and macOS does not reliably deliver a DidChangeOcclusionState(visible)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the state capture in the PR thread, occlusionState itself can latch stale rather than the notification merely being dropped, which is also why re-querying it here would not help. Worth stating that here since it justifies ignoring _visible.

Addressing review feedback: instead of unconditionally latching _visible = YES
on becomeActive, derive visibility from NSApplication.windows. NSWindow.isVisible
stays accurate even when occlusionState latches stale, so this still fixes the
freeze but no longer resumes an app that was activated while all of its windows
are minimized (plain Cmd-Tab does not deminiaturize, and a minimized window fires
no occlusion change to recover from).

Also clarify in the comment that occlusionState itself latches stale (re-querying
it would return the same value), fix the handleDidChangeOcclusionState doc comment
that named the wrong notification, and extend the lifecycle test to pin that
occlusion stays authoritative after a becomeActive resume and that activating with
no visible window does not resume.
@flutter-dashboard flutter-dashboard Bot removed the CICD Run CI/CD label Jul 6, 2026
@ellie-ka-nam

Copy link
Copy Markdown
Contributor Author

Thanks @bdero — and especially for the lldb capture, that's a much cleaner confirmation of the stale-occlusionState mechanism than my file-log traces.

Pushed a revision addressing the feedback:

  • becomeActive no longer unconditionally latches _visible; it now derives visibility from NSApplication.windows (isVisible), so an activation while all windows are minimized stays kHidden instead of resuming invisibly.
  • Reworded the comment to call out that occlusionState itself latches stale (re-querying it here would return the same value), which is what justifies not trusting _visible on resume.
  • Fixed the handleDidChangeOcclusionState doc comment that named the wrong notification.
  • Extended HandleLifecycleStates to also assert that (a) occlusion stays authoritative after a becomeActive resume — a subsequent not-visible occlusion still hides — and (b) activating with no visible window does not resume.

@bdero bdero added the CICD Run CI/CD label Jul 6, 2026
@knopp

knopp commented Jul 10, 2026

Copy link
Copy Markdown
Member

The fix seems good to me, the comment around it seems a bit too verbose.

@okorohelijah

Copy link
Copy Markdown
Contributor

@ellie-ka-nam Can you address the feedbacks?

@cbracken cbracken left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change also lgtm once suggestions have been addressed. Thanks for sending!

@LongCatIsLooong LongCatIsLooong added the waiting for response The Flutter team cannot make further progress on this issue until the original reporter responds label Jul 23, 2026
Condense the handleWillBecomeActive rationale and drop the duplicated
mechanism explanation from the regression test (it now lives in the
source comment), addressing review feedback that the comments were too
verbose. No behavior change.
@ellie-ka-nam

ellie-ka-nam commented Jul 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the ping @okorohelijah, and thanks everyone for the reviews! The feedback has been addressed:

  • @bdero — resume is now gated on NSWindow.isVisible instead of unconditionally latching _visible, so an app activated with all windows minimized stays hidden; the comment now notes that occlusionState itself can latch stale (re-reading it wouldn't help); the handleDidChangeOcclusionState doc comment is corrected; and the test gained the follow-up not-visible-occlusion → kHidden step (confirming the occlusion handler stays authoritative after a becomeActive resume) plus a minimized-windows case.
  • @knopp — shortened the comments in the latest push.

Ready for another look 🙏

@github-actions github-actions Bot removed the waiting for response The Flutter team cannot make further progress on this issue until the original reporter responds label Jul 24, 2026

@bdero bdero left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@bdero

bdero commented Jul 31, 2026 •

Copy link
Copy Markdown
Member

FYI @gaaclarke / @cbracken / @jtmcdole, I would recommend cherry-picking this into 3.47 stable... I'm not sure why there isn't an enormous flood of users reproducing this all the time. Maybe there just aren't a lot of macOS Flutter users yet, or it only repos with certain macOS versions, or it's an AppKit bug that only happens when you have a lot of windows open... I don't know.

What I do know is that from extensive use of the macOS embedder, I'm hitting this many times per day on master, and it's an extraordinarily severe showstopper bug that makes Flutter unshippable for anyone that may encounter it.

@gaaclarke gaaclarke added cp: beta cherry pick this pull request to beta release candidate branch CICD Run CI/CD labels Jul 31, 2026
@gaaclarke

Copy link
Copy Markdown
Member

@cbracken I'm not sure if brandon and I are sufficient to get this landed. Since you'll start working before us in your time zone can you see that this gets landed and spawn a cherry-pick for beta, please? We'll need to land it monday pacific time if we want it in for 3.47.

@cbracken cbracken left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Digging through history, it looks like this bug was introduced with the original lifecycle implementation in flutter-team-archive/engine#40542, reverted in flutter-team-archive/engine#42384, relanded in flutter-team-archive/engine#42418.

In the original code, _visible is only ever updated by the occlusion notification and handleWillBecomeActive relied entirely on that. That code dates back to 2023; looks like #155977 was reported about a year and a little after, so the timing tracks. I suspect how often you his this is pretty dependent on user habits/workflow which might be why @bdero is hitting this so frequently.

@ellie-ka-nam thanks for the excellent analysis and the fix.

LGTM stamp from a Japanese personal seal

@cbracken
cbracken enabled auto-merge August 3, 2026 02:57
@flutter-dashboard flutter-dashboard Bot removed the CICD Run CI/CD label Aug 3, 2026
@cbracken cbracken added the CICD Run CI/CD label Aug 3, 2026
@cbracken
cbracken added this pull request to the merge queue Aug 3, 2026
Merged via the queue into flutter:master with commit 272fe23 Aug 3, 2026
35 checks passed
@cbracken cbracken added cp: beta cherry pick this pull request to beta release candidate branch and removed cp: beta cherry pick this pull request to beta release candidate branch labels Aug 3, 2026
auto-submit Bot pushed a commit to flutter/packages that referenced this pull request Aug 10, 2026
…12406)

Manual roll Flutter from e52f01c920ad to b766512c65d8 (42 revisions)

Manual roll requested by stuartmorgan@google.com

flutter/flutter@e52f01c...b766512

2026-08-04 engine-flutter-autoroll@skia.org Roll Dart SDK from 2a799a2404e9 to 9859c0a39adb (4 revisions) (flutter/flutter#190521)
2026-08-04 154381524+flutteractionsbot@users.noreply.github.com Revert: iOS: Eliminate use of IOSContextNoop in platform view tests (flutter/flutter#190501)
2026-08-04 125822178+guszxtavo@users.noreply.github.com [Impeller] Enable ETC2/ASTC LDR/BC texture compression features at Vulkan device creation (flutter/flutter#189303)
2026-08-03 30870216+gaaclarke@users.noreply.github.com Remove openglessdf from impeller_unittests. (flutter/flutter#190469)
2026-08-03 1961493+harryterkelsen@users.noreply.github.com [web] Unify image decoding and codecs on CanvasKit and Skwasm (flutter/flutter#188573)
2026-08-03 chris@bracken.jp iOS: Eliminate use of IOSContextNoop in platform view tests (flutter/flutter#190419)
2026-08-03 evanwall@buffalo.edu Add path rendering benchmarks (flutter/flutter#188654)
2026-08-03 97480502+b-luk@users.noreply.github.com Add windows platform support for primitive_shape_test integration test (flutter/flutter#190464)
2026-08-03 engine-flutter-autoroll@skia.org Roll Skia from 958c1c1921a1 to a08d918ebd6a (3 revisions) (flutter/flutter#190467)
2026-08-03 chris@bracken.jp tests: add --ios-runtime param (flutter/flutter#190414)
2026-08-03 chris@bracken.jp iOS: Remove the synchronous first-frame wait (flutter/flutter#190432)
2026-08-03 chris@bracken.jp iOS: Eliminate the Impeller/Skia backend selection params (flutter/flutter#190416)
2026-08-03 chris@bracken.jp iOS,macOS: Use @autoclosure in Logger (flutter/flutter#190417)
2026-08-03 chris@bracken.jp tools: Support FLUTTER_HOST_ARCH in update_dart_sdk scripts (flutter/flutter#190421)
2026-08-03 chris@bracken.jp iOS: Hardcode rendering API to Metal in tests (no-op) (flutter/flutter#190422)
2026-08-03 chris@bracken.jp a11y: Map disabled/read-only semantics to AX node restriction (flutter/flutter#190353)
2026-08-03 kevmoo@users.noreply.github.com [Infra] Replace defunct umbrella template with Wasm issue form (flutter/flutter#190471)
2026-08-03 engine-flutter-autoroll@skia.org Roll Skia from abecb0dc02c1 to 958c1c1921a1 (4 revisions) (flutter/flutter#190459)
2026-08-03 engine-flutter-autoroll@skia.org Roll Dart SDK from 65b163be2485 to 2a799a2404e9 (3 revisions) (flutter/flutter#190454)
2026-08-03 engine-flutter-autoroll@skia.org Roll Skia from 68efb3f2ad16 to abecb0dc02c1 (1 revision) (flutter/flutter#190443)
2026-08-03 engine-flutter-autoroll@skia.org Roll Packages from 5351d8c to ac87e65 (4 revisions) (flutter/flutter#190441)
2026-08-03 engine-flutter-autoroll@skia.org Roll Skia from 5a761eb826c1 to 68efb3f2ad16 (1 revision) (flutter/flutter#190440)
2026-08-03 engine-flutter-autoroll@skia.org Roll Skia from 4c9f8b4805e2 to 5a761eb826c1 (1 revision) (flutter/flutter#190437)
2026-08-03 ellie@edencrew.com [macOS] Resume app lifecycle on becomeActive to avoid frozen UI after occlusion (flutter/flutter#188772)
2026-08-03 engine-flutter-autoroll@skia.org Roll Skia from 39cda9d6d7d2 to 4c9f8b4805e2 (6 revisions) (flutter/flutter#190426)
2026-08-03 engine-flutter-autoroll@skia.org Roll Skia from df13bfb5a54e to 39cda9d6d7d2 (2 revisions) (flutter/flutter#190425)
2026-08-02 chris@bracken.jp iOS: Serialise CADisplayLink access in VSyncClient tests (flutter/flutter#190335)
2026-08-02 engine-flutter-autoroll@skia.org Roll Skia from 32329e5643b5 to df13bfb5a54e (1 revision) (flutter/flutter#190394)
2026-08-02 bdero@google.com [Impeller] Skip binding dead-code-eliminated resources on Metal (flutter/flutter#190040)
2026-08-01 bdero@google.com [Flutter GPU] Raise Dart errors for invalid render pipelines and memoize per-draw pipeline state (flutter/flutter#189899)
2026-08-01 41930132+hellohuanlin@users.noreply.github.com Revert "Improve non rect platform view rendering  (#182662)" (flutter/flutter#190003)
2026-08-01 engine-flutter-autoroll@skia.org Roll Skia from ebf50520d720 to 32329e5643b5 (1 revision) (flutter/flutter#190389)
2026-08-01 engine-flutter-autoroll@skia.org Roll Skia from f73c4510d12d to ebf50520d720 (6 revisions) (flutter/flutter#190376)
2026-07-31 97480502+b-luk@users.noreply.github.com Primitive shape integration test (flutter/flutter#190368)
2026-07-31 97480502+b-luk@users.noreply.github.com Eliminate some early returns in uber_sdf.frag to fix broken UberSDF AA on Windows (flutter/flutter#190260)
2026-07-31 codefu@google.com chore: swiftshader mirrored + llvm16 (flutter/flutter#181225)
2026-07-31 1961493+harryterkelsen@users.noreply.github.com [web] Remove in-repo agent documentation (flutter/flutter#190326)
2026-07-31 30870216+gaaclarke@users.noreply.github.com [windows]: Uses offscreen MSAA when implicit msaa isn't available. (flutter/flutter#190256)
2026-07-31 srawlins@google.com flutter_tools: Use new FileSystemExtension from devtools (flutter/flutter#190360)
2026-07-31 engine-flutter-autoroll@skia.org Roll Dart SDK from c3acfc2479f6 to 65b163be2485 (1 revision) (flutter/flutter#190358)
2026-07-31 magder@google.com Use devicectl for screenshots on Xcode 27, remove idevicescreenshot artifact (flutter/flutter#189091)
2026-07-31 engine-flutter-autoroll@skia.org Roll Skia from 7ef86a5b0eb9 to f73c4510d12d (1 revision) (flutter/flutter#190352)

If this roll has caused a breakage, revert this CL and stop the roller
...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: desktop Running on desktop CICD Run CI/CD cp: beta cherry pick this pull request to beta release candidate branch engine flutter/engine related. See also e: labels. platform-macos Building on or for macOS specifically team-macos Owned by the macOS platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flutter macOS desktop app freezes after switching applications using Mission Control.

7 participants