fix: re-enable MacWebContentsOcclusion with embedder window fix (#50579)

* fix: re-enable MacWebContentsOcclusion with embedder window fix

Replace the full revert of Chromium's MacWebContentsOcclusion cleanup
with a targeted patch that handles embedder windows shown after
WebContentsViewCocoa attachment. This lets us drop the feature flag
disable in feature_list.cc and re-enable upstream occlusion tracking.

Adds tests for show/hide event counts on macOS and visibility tracking
across multiple child WebContentsViews.

* test: drop show/hide event count assertion

The assertion that 'show' fires exactly once per w.show() call is not
an API guarantee - macOS can send multiple occlusion state
notifications during a single show() when other windows are on screen
(common on CI after hundreds of prior tests). The
visibilitychange-count test in api-web-contents-view-spec.ts covers
the actual invariant we care about.

* fix: ignore WebContentsOcclusionCheckerMac synthetic notifications in window delegate

On macOS 13.3-25.x, Chromium's occlusion checker enables manual
frame-intersection detection and posts synthetic
NSWindowDidChangeOcclusionStateNotification tagged with its class name
in userInfo. These fire when the checker's NSContainsRect heuristic
decides a window is covered by another window's frame, but the real
-[NSWindow occlusionState] hasn't changed.

Our delegate was treating these the same as real macOS notifications
and emitting show/hide events based on occlusionState, which was
unchanged - resulting in spurious duplicate show events when e.g.
Quick Look opened and its frame intersected the BrowserWindow.
This commit is contained in:
Samuel Attard
2026-03-30 17:13:00 -04:00
committed by GitHub
parent 3e1666be08
commit bbbcae1a12
6 changed files with 179 additions and 287 deletions

View File

@@ -119,7 +119,7 @@ build_disable_thin_lto_mac.patch
feat_corner_smoothing_css_rule_and_blink_painting.patch
build_add_public_config_simdutf_config.patch
fix_multiple_scopedpumpmessagesinprivatemodes_instances.patch
revert_code_health_clean_up_stale_macwebcontentsocclusion.patch
fix_handle_embedder_windows_shown_after_webcontentsviewcocoa_attach.patch
feat_add_signals_when_embedder_cleanup_callbacks_run_for.patch
feat_separate_content_settings_callback_for_sync_and_async_clipboard.patch
fix_win32_synchronous_spellcheck.patch

View File

@@ -0,0 +1,81 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Samuel Attard <sattard@anthropic.com>
Date: Mon, 30 Mar 2026 03:05:40 -0700
Subject: fix: handle embedder windows shown after WebContentsViewCocoa attach
The occlusion checker assumes windows are shown before or at the same
time as a WebContentsViewCocoa is attached. Embedders like Electron
support creating a window hidden, attaching web contents, and showing
later. This breaks three assumptions:
1. updateWebContentsVisibility only checks -[NSWindow isOccluded], which
defaults to NO for never-shown windows, so viewDidMoveToWindow
incorrectly reports kVisible for hidden windows.
2. windowChangedOcclusionState: only responds to checker-originated
notifications, but setOccluded: early-returns when isOccluded doesn't
change. A hidden window's isOccluded is NO and stays NO after show(),
so no checker notification fires on show and the view never updates
to kVisible.
3. performOcclusionStateUpdates iterates orderedWindows and marks
not-yet-shown windows as occluded (their occlusionState lacks the
Visible bit), which stops painting before first show.
Fix by also checking occlusionState in updateWebContentsVisibility,
responding to macOS-originated notifications in
windowChangedOcclusionState:, and skipping non-visible windows in
performOcclusionStateUpdates.
This patch can be removed if the changes are upstreamed to Chromium.
diff --git a/content/app_shim_remote_cocoa/web_contents_occlusion_checker_mac.mm b/content/app_shim_remote_cocoa/web_contents_occlusion_checker_mac.mm
index a5570988c3721d9f6bd05c402a7658d3af6f2c2c..54aaffde30c14a27068f89b6de6123abd6ea0660 100644
--- a/content/app_shim_remote_cocoa/web_contents_occlusion_checker_mac.mm
+++ b/content/app_shim_remote_cocoa/web_contents_occlusion_checker_mac.mm
@@ -400,9 +400,11 @@ - (void)performOcclusionStateUpdates {
for (NSWindow* window in windowsFromFrontToBack) {
// The fullscreen transition causes spurious occlusion notifications.
// See https://crbug.com/1081229 . Also, ignore windows that don't have
- // web contentses.
+ // web contentses, and windows that aren't visible (embedders like
+ // Electron may create windows hidden with web contents already attached;
+ // marking these as occluded would stop painting before first show).
if (window == _windowReceivingFullscreenTransitionNotifications ||
- ![window containsWebContentsViewCocoa])
+ ![window isVisible] || ![window containsWebContentsViewCocoa])
continue;
[window setOccluded:[self isWindowOccluded:window
diff --git a/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm b/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm
index 1ef2c9052262eccdbc40030746a858b7f30ac469..34708d45274f95b5f35cdefad98ad4a1c3c28e1c 100644
--- a/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm
+++ b/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm
@@ -477,7 +477,8 @@ - (void)updateWebContentsVisibility {
Visibility visibility = Visibility::kVisible;
if ([self isHiddenOrHasHiddenAncestor] || ![self window])
visibility = Visibility::kHidden;
- else if ([[self window] isOccluded])
+ else if ([[self window] isOccluded] ||
+ !([[self window] occlusionState] & NSWindowOcclusionStateVisible))
visibility = Visibility::kOccluded;
[self updateWebContentsVisibility:visibility];
@@ -521,11 +522,12 @@ - (void)viewWillMoveToWindow:(NSWindow*)newWindow {
}
- (void)windowChangedOcclusionState:(NSNotification*)aNotification {
- // Only respond to occlusion notifications sent by the occlusion checker.
- NSDictionary* userInfo = [aNotification userInfo];
- NSString* occlusionCheckerKey = [WebContentsOcclusionCheckerMac className];
- if (userInfo[occlusionCheckerKey] != nil)
- [self updateWebContentsVisibility];
+ // Respond to occlusion notifications from both macOS and the occlusion
+ // checker. Embedders (e.g. Electron) may attach a WebContentsViewCocoa to
+ // a window that has not yet been shown; macOS will notify us when the
+ // window's occlusion state changes, but the occlusion checker will not
+ // because -[NSWindow isOccluded] remains NO before and after show.
+ [self updateWebContentsVisibility];
}
- (void)viewDidMoveToWindow {

View File

@@ -1,279 +0,0 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: David Sanders <dsanders11@ucsbalum.com>
Date: Wed, 8 Jan 2025 23:53:27 -0800
Subject: Revert "Code Health: Clean up stale MacWebContentsOcclusion"
Chrome has removed this WebContentsOcclusion feature flag upstream,
which is now causing our visibility tests to break. This patch
restores the legacy occlusion behavior to ensure the roll can continue
while we debug the issue.
This patch can be removed when the root cause because the visibility
specs failing on MacOS only is debugged and fixed. It should be removed
before Electron 35's stable date.
Refs: https://chromium-review.googlesource.com/c/chromium/src/+/6078344
This partially (leaves the removal of the feature flag) reverts
ef865130abd5539e7bce12308659b19980368f12.
diff --git a/content/app_shim_remote_cocoa/web_contents_occlusion_checker_mac.h b/content/app_shim_remote_cocoa/web_contents_occlusion_checker_mac.h
index 04c7635cc093d9d676869383670a8f2199f14ac6..52d76e804e47ab0b56016d26262d6d67cbc00875 100644
--- a/content/app_shim_remote_cocoa/web_contents_occlusion_checker_mac.h
+++ b/content/app_shim_remote_cocoa/web_contents_occlusion_checker_mac.h
@@ -11,6 +11,8 @@
#include "base/metrics/field_trial_params.h"
#import "content/app_shim_remote_cocoa/web_contents_view_cocoa.h"
+extern CONTENT_EXPORT const base::FeatureParam<bool>
+ kEnhancedWindowOcclusionDetection;
extern CONTENT_EXPORT const base::FeatureParam<bool>
kDisplaySleepAndAppHideDetection;
diff --git a/content/app_shim_remote_cocoa/web_contents_occlusion_checker_mac.mm b/content/app_shim_remote_cocoa/web_contents_occlusion_checker_mac.mm
index a5570988c3721d9f6bd05c402a7658d3af6f2c2c..0a2dba6aa2d48bc39d2a55c8b4d6606744c10ca7 100644
--- a/content/app_shim_remote_cocoa/web_contents_occlusion_checker_mac.mm
+++ b/content/app_shim_remote_cocoa/web_contents_occlusion_checker_mac.mm
@@ -14,9 +14,16 @@
#include "base/mac/mac_util.h"
#include "base/metrics/field_trial_params.h"
#include "base/no_destructor.h"
+#include "content/common/features.h"
#include "content/public/browser/content_browser_client.h"
#include "content/public/common/content_client.h"
+using features::kMacWebContentsOcclusion;
+
+// Experiment features.
+const base::FeatureParam<bool> kEnhancedWindowOcclusionDetection{
+ &kMacWebContentsOcclusion, "EnhancedWindowOcclusionDetection", false};
+
namespace {
NSString* const kWindowDidChangePositionInWindowList =
@@ -125,7 +132,8 @@ - (void)dealloc {
- (BOOL)isManualOcclusionDetectionEnabled {
return [WebContentsOcclusionCheckerMac
- manualOcclusionDetectionSupportedForCurrentMacOSVersion];
+ manualOcclusionDetectionSupportedForCurrentMacOSVersion] &&
+ kEnhancedWindowOcclusionDetection.Get();
}
// Alternative implementation of orderWindow:relativeTo:. Replaces
diff --git a/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm b/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm
index 1ef2c9052262eccdbc40030746a858b7f30ac469..c7101b0d71826b05f61bfe0e74429d922769e792 100644
--- a/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm
+++ b/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm
@@ -15,6 +15,7 @@
#import "content/app_shim_remote_cocoa/web_drag_source_mac.h"
#import "content/browser/web_contents/web_contents_view_mac.h"
#import "content/browser/web_contents/web_drag_dest_mac.h"
+#include "content/common/features.h"
#include "content/public/browser/content_browser_client.h"
#include "content/public/common/content_client.h"
#include "ui/base/clipboard/clipboard_constants.h"
@@ -27,6 +28,7 @@
#include "ui/resources/grit/ui_resources.h"
using content::DropData;
+using features::kMacWebContentsOcclusion;
using remote_cocoa::mojom::DraggingInfo;
using remote_cocoa::mojom::SelectionDirection;
@@ -122,12 +124,15 @@ @implementation WebContentsViewCocoa {
WebDragSource* __strong _dragSource;
NSDragOperation _dragOperation;
+ BOOL _inFullScreenTransition;
BOOL _willSetWebContentsOccludedAfterDelay;
}
+ (void)initialize {
- // Create the WebContentsOcclusionCheckerMac shared instance.
- [WebContentsOcclusionCheckerMac sharedInstance];
+ if (base::FeatureList::IsEnabled(kMacWebContentsOcclusion)) {
+ // Create the WebContentsOcclusionCheckerMac shared instance.
+ [WebContentsOcclusionCheckerMac sharedInstance];
+ }
}
- (instancetype)initWithViewsHostableView:(ui::ViewsHostableView*)v {
@@ -438,6 +443,7 @@ - (void)updateWebContentsVisibility:
(remote_cocoa::mojom::Visibility)visibility {
using remote_cocoa::mojom::Visibility;
+ DCHECK(base::FeatureList::IsEnabled(kMacWebContentsOcclusion));
if (!_host)
return;
@@ -483,6 +489,20 @@ - (void)updateWebContentsVisibility {
[self updateWebContentsVisibility:visibility];
}
+- (void)legacyUpdateWebContentsVisibility {
+ using remote_cocoa::mojom::Visibility;
+ if (!_host || _inFullScreenTransition)
+ return;
+ Visibility visibility = Visibility::kVisible;
+ if ([self isHiddenOrHasHiddenAncestor] || ![self window])
+ visibility = Visibility::kHidden;
+ else if ([[self window] occlusionState] & NSWindowOcclusionStateVisible)
+ visibility = Visibility::kVisible;
+ else
+ visibility = Visibility::kOccluded;
+ _host->OnWindowVisibilityChanged(visibility);
+}
+
- (void)resizeSubviewsWithOldSize:(NSSize)oldBoundsSize {
// Subviews do not participate in auto layout unless the the size this view
// changes. This allows RenderWidgetHostViewMac::SetBounds(..) to select a
@@ -505,11 +525,39 @@ - (void)viewWillMoveToWindow:(NSWindow*)newWindow {
NSWindow* oldWindow = [self window];
+ if (base::FeatureList::IsEnabled(kMacWebContentsOcclusion)) {
+ if (oldWindow) {
+ [notificationCenter
+ removeObserver:self
+ name:NSWindowDidChangeOcclusionStateNotification
+ object:oldWindow];
+ }
+
+ if (newWindow) {
+ [notificationCenter
+ addObserver:self
+ selector:@selector(windowChangedOcclusionState:)
+ name:NSWindowDidChangeOcclusionStateNotification
+ object:newWindow];
+ }
+
+ return;
+ }
+
+ _inFullScreenTransition = NO;
if (oldWindow) {
- [notificationCenter
- removeObserver:self
- name:NSWindowDidChangeOcclusionStateNotification
- object:oldWindow];
+ NSArray* notificationsToRemove = @[
+ NSWindowDidChangeOcclusionStateNotification,
+ NSWindowWillEnterFullScreenNotification,
+ NSWindowDidEnterFullScreenNotification,
+ NSWindowWillExitFullScreenNotification,
+ NSWindowDidExitFullScreenNotification
+ ];
+ for (NSString* notificationName in notificationsToRemove) {
+ [notificationCenter removeObserver:self
+ name:notificationName
+ object:oldWindow];
+ }
}
if (newWindow) {
@@ -517,26 +565,66 @@ - (void)viewWillMoveToWindow:(NSWindow*)newWindow {
selector:@selector(windowChangedOcclusionState:)
name:NSWindowDidChangeOcclusionStateNotification
object:newWindow];
+ // The fullscreen transition causes spurious occlusion notifications.
+ // See https://crbug.com/1081229
+ [notificationCenter addObserver:self
+ selector:@selector(fullscreenTransitionStarted:)
+ name:NSWindowWillEnterFullScreenNotification
+ object:newWindow];
+ [notificationCenter addObserver:self
+ selector:@selector(fullscreenTransitionComplete:)
+ name:NSWindowDidEnterFullScreenNotification
+ object:newWindow];
+ [notificationCenter addObserver:self
+ selector:@selector(fullscreenTransitionStarted:)
+ name:NSWindowWillExitFullScreenNotification
+ object:newWindow];
+ [notificationCenter addObserver:self
+ selector:@selector(fullscreenTransitionComplete:)
+ name:NSWindowDidExitFullScreenNotification
+ object:newWindow];
}
}
- (void)windowChangedOcclusionState:(NSNotification*)aNotification {
- // Only respond to occlusion notifications sent by the occlusion checker.
- NSDictionary* userInfo = [aNotification userInfo];
- NSString* occlusionCheckerKey = [WebContentsOcclusionCheckerMac className];
- if (userInfo[occlusionCheckerKey] != nil)
- [self updateWebContentsVisibility];
+ if (!base::FeatureList::IsEnabled(kMacWebContentsOcclusion)) {
+ [self legacyUpdateWebContentsVisibility];
+ return;
+ }
+}
+
+- (void)fullscreenTransitionStarted:(NSNotification*)notification {
+ _inFullScreenTransition = YES;
+}
+
+- (void)fullscreenTransitionComplete:(NSNotification*)notification {
+ _inFullScreenTransition = NO;
}
- (void)viewDidMoveToWindow {
+ if (!base::FeatureList::IsEnabled(kMacWebContentsOcclusion)) {
+ [self legacyUpdateWebContentsVisibility];
+ return;
+ }
+
[self updateWebContentsVisibility];
}
- (void)viewDidHide {
+ if (!base::FeatureList::IsEnabled(kMacWebContentsOcclusion)) {
+ [self legacyUpdateWebContentsVisibility];
+ return;
+ }
+
[self updateWebContentsVisibility];
}
- (void)viewDidUnhide {
+ if (!base::FeatureList::IsEnabled(kMacWebContentsOcclusion)) {
+ [self legacyUpdateWebContentsVisibility];
+ return;
+ }
+
[self updateWebContentsVisibility];
}
diff --git a/content/common/features.cc b/content/common/features.cc
index 1272f3f588e00162a2b8665b23a4d23ef06e8ede..ff1a27d1928e4d1af10b46d570a13519a42f62d8 100644
--- a/content/common/features.cc
+++ b/content/common/features.cc
@@ -395,6 +395,14 @@ BASE_FEATURE(kInterestGroupUpdateIfOlderThan, base::FEATURE_ENABLED_BY_DEFAULT);
BASE_FEATURE(kIOSurfaceCapturer, base::FEATURE_ENABLED_BY_DEFAULT);
#endif
+// Feature that controls whether WebContentsOcclusionChecker should handle
+// occlusion notifications.
+#if BUILDFLAG(IS_MAC)
+BASE_FEATURE(kMacWebContentsOcclusion,
+ "MacWebContentsOcclusion",
+ base::FEATURE_ENABLED_BY_DEFAULT);
+#endif
+
// When enabled, child process will not terminate itself when IPC is reset.
BASE_FEATURE(kKeepChildProcessAfterIPCReset, base::FEATURE_DISABLED_BY_DEFAULT);
diff --git a/content/common/features.h b/content/common/features.h
index dcfbeeab7b80b687ada74f6826107e26901e7cf1..980a2df8bb93c4cf569384fad1ed0982fc77ef68 100644
--- a/content/common/features.h
+++ b/content/common/features.h
@@ -150,6 +150,9 @@ CONTENT_EXPORT BASE_DECLARE_FEATURE(kInterestGroupUpdateIfOlderThan);
#if BUILDFLAG(IS_MAC)
CONTENT_EXPORT BASE_DECLARE_FEATURE(kIOSurfaceCapturer);
#endif
+#if BUILDFLAG(IS_MAC)
+CONTENT_EXPORT BASE_DECLARE_FEATURE(kMacWebContentsOcclusion);
+#endif
CONTENT_EXPORT BASE_DECLARE_FEATURE(kKeepChildProcessAfterIPCReset);
CONTENT_EXPORT BASE_DECLARE_FEATURE(kLocalNetworkAccessForWorkers);

View File

@@ -87,13 +87,6 @@ void InitializeFeatureList() {
std::string(",") + sandbox::policy::features::kNetworkServiceSandbox.name;
#endif
#if BUILDFLAG(IS_MAC)
disable_features +=
// MacWebContentsOcclusion is causing some odd visibility
// issues with multiple web contents
std::string(",") + features::kMacWebContentsOcclusion.name;
#endif
#if BUILDFLAG(ENABLE_PDF_VIEWER)
// Enable window.showSaveFilePicker api for saving pdf files.
// Refs https://issues.chromium.org/issues/373852607

View File

@@ -43,6 +43,14 @@ using TitleBarStyle = electron::NativeWindowMac::TitleBarStyle;
#pragma mark - NSWindowDelegate
- (void)windowDidChangeOcclusionState:(NSNotification*)notification {
// Chromium's WebContentsOcclusionCheckerMac posts synthetic occlusion
// notifications tagged with its class name in userInfo. These reflect the
// checker's manual frame-intersection heuristic, not an actual macOS
// occlusion state change, so the real occlusionState hasn't changed and
// emitting show/hide in response would be spurious.
if (notification.userInfo[@"WebContentsOcclusionCheckerMac"] != nil)
return;
// notification.object is the window that changed its state.
// It's safe to use self.window instead if you don't assign one delegate to
// many windows

View File

@@ -3,6 +3,7 @@ import { BaseWindow, BrowserWindow, View, WebContentsView, webContents, screen }
import { expect } from 'chai';
import { once } from 'node:events';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { HexColors, ScreenCapture, hasCapturableScreen, nextFrameTime } from './lib/screen-helpers';
import { defer, ifdescribe, waitUntil } from './lib/spec-helpers';
@@ -309,6 +310,94 @@ describe('WebContentsView', () => {
}
expect(visibilityState).to.equal('visible');
});
it('tracks visibility for multiple child WebContentsViews', async () => {
const w = new BaseWindow({ show: false });
const cv = new View();
w.setContentView(cv);
const v1 = new WebContentsView();
const v2 = new WebContentsView();
cv.addChildView(v1);
cv.addChildView(v2);
v1.setBounds({ x: 0, y: 0, width: 400, height: 300 });
v2.setBounds({ x: 0, y: 300, width: 400, height: 300 });
await v1.webContents.loadURL('about:blank');
await v2.webContents.loadURL('about:blank');
await expect(waitUntil(async () => await haveVisibilityState(v1, 'hidden'))).to.eventually.be.fulfilled();
await expect(waitUntil(async () => await haveVisibilityState(v2, 'hidden'))).to.eventually.be.fulfilled();
w.show();
await expect(waitUntil(async () => await haveVisibilityState(v1, 'visible'))).to.eventually.be.fulfilled();
await expect(waitUntil(async () => await haveVisibilityState(v2, 'visible'))).to.eventually.be.fulfilled();
w.hide();
await expect(waitUntil(async () => await haveVisibilityState(v1, 'hidden'))).to.eventually.be.fulfilled();
await expect(waitUntil(async () => await haveVisibilityState(v2, 'hidden'))).to.eventually.be.fulfilled();
});
it('tracks visibility independently when a child WebContentsView is hidden via setVisible', async () => {
const w = new BaseWindow();
const cv = new View();
w.setContentView(cv);
const v1 = new WebContentsView();
const v2 = new WebContentsView();
cv.addChildView(v1);
cv.addChildView(v2);
v1.setBounds({ x: 0, y: 0, width: 400, height: 300 });
v2.setBounds({ x: 0, y: 300, width: 400, height: 300 });
await v1.webContents.loadURL('about:blank');
await v2.webContents.loadURL('about:blank');
await expect(waitUntil(async () => await haveVisibilityState(v1, 'visible'))).to.eventually.be.fulfilled();
await expect(waitUntil(async () => await haveVisibilityState(v2, 'visible'))).to.eventually.be.fulfilled();
v1.setVisible(false);
await expect(waitUntil(async () => await haveVisibilityState(v1, 'hidden'))).to.eventually.be.fulfilled();
// v2 should remain visible while v1 is hidden
expect(await v2.webContents.executeJavaScript('document.visibilityState')).to.equal('visible');
v1.setVisible(true);
await expect(waitUntil(async () => await haveVisibilityState(v1, 'visible'))).to.eventually.be.fulfilled();
});
it('fires a single visibilitychange event per show/hide transition', async () => {
const w = new BaseWindow({ show: false });
const v = new WebContentsView();
w.setContentView(v);
await v.webContents.loadURL('about:blank');
await v.webContents.executeJavaScript(`
window.__visChanges = [];
document.addEventListener('visibilitychange', () => {
window.__visChanges.push(document.visibilityState);
});
`);
w.show();
await expect(waitUntil(async () => await haveVisibilityState(v, 'visible'))).to.eventually.be.fulfilled();
// Give any delayed/queued occlusion updates time to fire.
await setTimeoutAsync(1500);
w.hide();
await expect(waitUntil(async () => await haveVisibilityState(v, 'hidden'))).to.eventually.be.fulfilled();
await setTimeoutAsync(1500);
const changes = await v.webContents.executeJavaScript('window.__visChanges');
// Expect exactly one 'visible' followed by one 'hidden'. Extra events
// would indicate the occlusion checker is causing spurious transitions.
expect(changes).to.deep.equal(['visible', 'hidden']);
});
});
describe('setBorderRadius', () => {