Repository navigation
[macOS] Resume app lifecycle on becomeActive to avoid frozen UI after occlusion - #188772
Conversation
… 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
There was a problem hiding this comment.
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.
|
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 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! 🙏 |
|
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. At the same moment, Re-querying Emulating this change on the stuck process via the debugger, setting |
bdero
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
|
Thanks @bdero — and especially for the lldb capture, that's a much cleaner confirmation of the stale- Pushed a revision addressing the feedback:
|
|
The fix seems good to me, the comment around it seems a bit too verbose. |
|
@ellie-ka-nam Can you address the feedbacks? |
cbracken
left a comment
There was a problem hiding this comment.
This change also lgtm once suggestions have been addressed. Thanks for sending!
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.
|
Thanks for the ping @okorohelijah, and thanks everyone for the reviews! The feedback has been addressed:
Ready for another look 🙏 |
|
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. |
|
@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. |
There was a problem hiding this comment.
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.
…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 ...

Fixes #155977
Problem
FlutterEnginedrives the framework'sAppLifecycleStatefrom two inputs:_active— toggled byapplicationWillBecomeActive/applicationWillResignActive_visible— toggled only byapplicationDidChangeOcclusionStatehandleWillBecomeActiveresumed only when_visiblewas alreadytrue, otherwise it sentkHidden: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 deliversvisible=NOwhen leaving but never deliversvisible=YESwhen returning. When that happens:handleDidChangeOcclusionStatesets_visible = NO, sendskHidden(framework disables frames — correct).applicationWillBecomeActivefires, but_visibleis still stale-NO, sohandleWillBecomeActivesendskHiddenagain.hidden: animations are muted,scheduleFrameis gated, noOnVsyncis 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 thevisiblenotification (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
applicationWillBecomeActiveis, by definition, frontmost and visible to the user, so treat becoming-active as an authoritative "visible" signal and resume.handleDidChangeOcclusionStatestill authoritatively driveskHiddenwhen 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) andFlutterVSyncWaiter/FlutterDisplayLink(vsync) showed, on the freezing return:i.e.
applicationWillBecomeActivedoes fire on the return, but[[NSApplication sharedApplication] occlusionState]still reports not-visible at that instant and thevisiblenotification never arrives, so the old code re-sentkHidden. With the fix the same transition logs-> AppLifecycleState.resumedand frame production resumes immediately. TheFlutterVSyncWaiter/FlutterDisplayLinklayer 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
HandleLifecycleStatesinFlutterEngineTest.mm: with the occlusion state reading not-visible,handleWillBecomeActivenow expectskResumed(previouslykHidden), directly exercising the missed-visible-notification scenario, and the followinghandleWillResignActivenow expectskInactive.Pre-launch checklist