From 487c29b3e2e1ada8902e56bbde0efa29e893f0df Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Mon, 6 Apr 2026 17:05:28 -0400 Subject: [PATCH] fix: re-enable MacWebContentsOcclusion with embedder window fix (#50712) 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. --- patches/chromium/.patches | 2 +- ...wn_after_webcontentsviewcocoa_attach.patch | 81 +++++ ...ean_up_stale_macwebcontentsocclusion.patch | 279 ------------------ shell/browser/feature_list.cc | 7 - .../ui/cocoa/electron_ns_window_delegate.mm | 8 + spec/api-web-contents-view-spec.ts | 89 ++++++ 6 files changed, 179 insertions(+), 287 deletions(-) create mode 100644 patches/chromium/fix_handle_embedder_windows_shown_after_webcontentsviewcocoa_attach.patch delete mode 100644 patches/chromium/revert_code_health_clean_up_stale_macwebcontentsocclusion.patch diff --git a/patches/chromium/.patches b/patches/chromium/.patches index 77c4718050..7ef06cf326 100644 --- a/patches/chromium/.patches +++ b/patches/chromium/.patches @@ -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 diff --git a/patches/chromium/fix_handle_embedder_windows_shown_after_webcontentsviewcocoa_attach.patch b/patches/chromium/fix_handle_embedder_windows_shown_after_webcontentsviewcocoa_attach.patch new file mode 100644 index 0000000000..79f2fab7bf --- /dev/null +++ b/patches/chromium/fix_handle_embedder_windows_shown_after_webcontentsviewcocoa_attach.patch @@ -0,0 +1,81 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Samuel Attard +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 { diff --git a/patches/chromium/revert_code_health_clean_up_stale_macwebcontentsocclusion.patch b/patches/chromium/revert_code_health_clean_up_stale_macwebcontentsocclusion.patch deleted file mode 100644 index b23695801b..0000000000 --- a/patches/chromium/revert_code_health_clean_up_stale_macwebcontentsocclusion.patch +++ /dev/null @@ -1,279 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: David Sanders -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 -+ kEnhancedWindowOcclusionDetection; - extern CONTENT_EXPORT const base::FeatureParam - 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 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 60450dda6cdc81dae8d288df7c03a13c86beed0e..efce8544ae5b7d5b904d99b242af4a04144bb548 100644 ---- a/content/common/features.cc -+++ b/content/common/features.cc -@@ -393,6 +393,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 f26e5c9f95dd3cfd81bee53507d181c08df3a044..b6efabd4fd0ebfdbea904bc19918aec9a6ba0ff0 100644 ---- a/content/common/features.h -+++ b/content/common/features.h -@@ -149,6 +149,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); diff --git a/shell/browser/feature_list.cc b/shell/browser/feature_list.cc index 9d9fa2bd6e..4e100e0285 100644 --- a/shell/browser/feature_list.cc +++ b/shell/browser/feature_list.cc @@ -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 diff --git a/shell/browser/ui/cocoa/electron_ns_window_delegate.mm b/shell/browser/ui/cocoa/electron_ns_window_delegate.mm index 1d7db88b3d..888946b3d0 100644 --- a/shell/browser/ui/cocoa/electron_ns_window_delegate.mm +++ b/shell/browser/ui/cocoa/electron_ns_window_delegate.mm @@ -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 diff --git a/spec/api-web-contents-view-spec.ts b/spec/api-web-contents-view-spec.ts index 6f6c048093..0ad5e8c18a 100644 --- a/spec/api-web-contents-view-spec.ts +++ b/spec/api-web-contents-view-spec.ts @@ -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', () => {