diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 272c0cc7..915f1c4f 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -60,6 +60,10 @@ private extension WKWebView { } } + var browserPortalRequiresRenderingStateReattach: Bool { + browserPortalNeedsRenderingStateReattach + } + func browserPortalNotifyHidden(reason: String) { browserPortalNeedsRenderingStateReattach = true let firedSelectors = ["viewDidHide", "_exitInWindow"].filter { @@ -2060,6 +2064,9 @@ final class WindowBrowserPortal: NSObject { private var hasDeferredFullSyncScheduled = false private var hasExternalGeometrySyncScheduled = false private var geometryObservers: [NSObjectProtocol] = [] + // Keep generations monotonic even if a pending entry is cleared during hide/detach churn. + private var nextHostedWebViewRefreshGeneration: UInt64 = 0 + private var pendingHostedWebViewRefreshes: [ObjectIdentifier: PendingHostedWebViewRefresh] = [:] private struct Entry { weak var webView: WKWebView? @@ -2075,6 +2082,12 @@ final class WindowBrowserPortal: NSObject { var transientRecoveryRetriesRemaining: Int } + private struct PendingHostedWebViewRefresh { + var generation: UInt64 = 0 + var asyncWorkItem: DispatchWorkItem? + var delayedWorkItem: DispatchWorkItem? + } + private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:] private var webViewByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:] @@ -2503,6 +2516,22 @@ final class WindowBrowserPortal: NSObject { #endif } + private func cancelPendingHostedWebViewRefreshes( + for webViewId: ObjectIdentifier, + keepGeneration: Bool = false + ) { + guard var pending = pendingHostedWebViewRefreshes[webViewId] else { return } + pending.asyncWorkItem?.cancel() + pending.delayedWorkItem?.cancel() + if keepGeneration { + pending.asyncWorkItem = nil + pending.delayedWorkItem = nil + pendingHostedWebViewRefreshes[webViewId] = pending + } else { + pendingHostedWebViewRefreshes.removeValue(forKey: webViewId) + } + } + private func invalidateHostedWebViewGeometry( _ webView: WKWebView, in containerView: WindowBrowserSlotView, @@ -2523,6 +2552,16 @@ final class WindowBrowserPortal: NSObject { reason: String ) { guard !containerView.isHidden else { return } + let webViewId = ObjectIdentifier(webView) + + // Bind/reveal/fullscreen refreshes can stack up during a single layout churn. + // Keep only the latest follow-up passes so reattach work does not pile up on + // the main thread while browser panes are moving between hosts. + cancelPendingHostedWebViewRefreshes(for: webViewId, keepGeneration: true) + var pending = pendingHostedWebViewRefreshes[webViewId] ?? PendingHostedWebViewRefresh() + nextHostedWebViewRefreshGeneration &+= 1 + let generation = nextHostedWebViewRefreshGeneration + pending.generation = generation runHostedWebViewRefreshPass( webView, @@ -2531,8 +2570,10 @@ final class WindowBrowserPortal: NSObject { phase: "immediate", reattachRenderingState: true ) - DispatchQueue.main.async { [weak self, weak webView, weak containerView] in + + let asyncWorkItem = DispatchWorkItem { [weak self, weak webView, weak containerView] in guard let self, let webView, let containerView else { return } + guard self.pendingHostedWebViewRefreshes[webViewId]?.generation == generation else { return } self.runHostedWebViewRefreshPass( webView, in: containerView, @@ -2541,8 +2582,20 @@ final class WindowBrowserPortal: NSObject { reattachRenderingState: true ) } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self, weak webView, weak containerView] in - guard let self, let webView, let containerView else { return } + pending.asyncWorkItem = asyncWorkItem + + let delayedWorkItem = DispatchWorkItem { [weak self, weak webView, weak containerView] in + guard let self else { return } + defer { + if var current = self.pendingHostedWebViewRefreshes[webViewId], + current.generation == generation { + current.asyncWorkItem = nil + current.delayedWorkItem = nil + self.pendingHostedWebViewRefreshes[webViewId] = current + } + } + guard let webView, let containerView else { return } + guard self.pendingHostedWebViewRefreshes[webViewId]?.generation == generation else { return } self.runHostedWebViewRefreshPass( webView, in: containerView, @@ -2551,6 +2604,11 @@ final class WindowBrowserPortal: NSObject { reattachRenderingState: true ) } + pending.delayedWorkItem = delayedWorkItem + pendingHostedWebViewRefreshes[webViewId] = pending + + DispatchQueue.main.async(execute: asyncWorkItem) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03, execute: delayedWorkItem) } private enum HostedWebViewPresentationUpdateKind { @@ -2633,6 +2691,7 @@ final class WindowBrowserPortal: NSObject { } func detachWebView(withId webViewId: ObjectIdentifier) { + cancelPendingHostedWebViewRefreshes(for: webViewId) guard let entry = entriesByWebViewId.removeValue(forKey: webViewId) else { return } if let anchor = entry.anchorView { webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) @@ -2735,9 +2794,10 @@ final class WindowBrowserPortal: NSObject { func forceRefreshWebView(withId webViewId: ObjectIdentifier, reason: String) { guard ensureInstalled() else { return } + let refreshSource = "forceRefresh:\(reason)" synchronizeWebView( withId: webViewId, - source: "forceRefresh", + source: refreshSource, forcePresentationRefresh: true ) guard let entry = entriesByWebViewId[webViewId], @@ -2746,10 +2806,12 @@ final class WindowBrowserPortal: NSObject { !containerView.isHidden else { return } + // Portal-host replacement/fullscreen churn relies on forceRefresh to kick + // WebKit even when synchronizeWebView short-circuits or skips its refresh path. refreshHostedWebViewPresentation( webView, in: containerView, - reason: reason + reason: refreshSource ) } @@ -2999,6 +3061,7 @@ final class WindowBrowserPortal: NSObject { } let previousTransientRecoveryReason = entry.transientRecoveryReason func hideContainerView(reason: String) { + cancelPendingHostedWebViewRefreshes(for: webViewId) containerView.setPaneTopChromeHeight(0) containerView.setSearchOverlay(nil) containerView.setPaneDropContext(nil) @@ -3434,13 +3497,17 @@ final class WindowBrowserPortal: NSObject { let hostedInspectorAdjustedDuringSync = containerOwnsWebView && hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync") + let requiresRenderingStateReattach = webView.browserPortalRequiresRenderingStateReattach let presentationUpdateKind = HostedWebViewPresentationUpdateKind.resolve( reasons: refreshReasons ) + let shouldReapplyHostedInspectorPostRefresh = + presentationUpdateKind == .refresh && requiresRenderingStateReattach if !shouldHide, containerOwnsWebView, presentationUpdateKind != .none { if presentationUpdateKind == .refresh && hostedInspectorAdjustedDuringSync && - !recoveredFromTransientGeometry { + !recoveredFromTransientGeometry && + !requiresRenderingStateReattach { #if DEBUG dlog( "browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " + @@ -3468,9 +3535,12 @@ final class WindowBrowserPortal: NSObject { } } } - if containerOwnsWebView, !hostedInspectorAdjustedDuringSync { + if containerOwnsWebView, + (!hostedInspectorAdjustedDuringSync || shouldReapplyHostedInspectorPostRefresh) { // Keep the existing post-sync pass for cases where the inspector candidate - // appears only after WebKit settles, but avoid a second apply when sync already clamped it. + // appears only after WebKit settles. Re-run it after rendering-state reattach + // refreshes as well, because WebKit's enter/unhide relayout can overwrite the + // preferred divider position we already clamped during portal.sync. _ = hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync.postRefresh") } #if DEBUG