From b91954104489a8575c5376b224fe72b4516d6e5c Mon Sep 17 00:00:00 2001 From: Elvis Tran <40386529+elvistranhere@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:54:17 +1030 Subject: [PATCH] Fix panel resize stuttering when tiled with browser panels (#1969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix panel resize stuttering when tiled with browser panels (#1968) During divider drag, the portal sync system was doing O(N²) work per frame: each geometry callback synced ALL web views, and multiple callbacks fired per layout pass (setFrameSize + setFrameOrigin + layout). Two changes: 1. synchronizeWebViewForAnchor now only syncs the primary web view and defers the all-sync. Each panel fires its own geometry callback, so secondary syncs are redundant on the hot path. 2. HostContainerView.setFrameOrigin/setFrameSize use markGeometryDirtyIfNeeded which defers the callback to layout(), coalescing 2-3 notifications per frame into one. An async fallback ensures origin-only changes (without a subsequent layout) are still delivered. Co-Authored-By: Claude Opus 4.6 (1M context) * Fix premature geometryRevision increment in markGeometryDirtyIfNeeded Address reviewer feedback (Greptile, CodeRabbit): geometryRevision and lastReportedGeometryState are now only updated when the callback actually fires, not eagerly. This prevents updateNSView from seeing a premature revision delta and triggering a redundant synchronizeForAnchor before the coalesced notification arrives. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- Sources/BrowserWindowPortal.swift | 6 +++++- Sources/Panels/BrowserPanelView.swift | 30 +++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 646e8aba..272c0cc7 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -2904,7 +2904,11 @@ final class WindowBrowserPortal: NSObject { synchronizeWebView(withId: primaryWebViewId, source: "anchorPrimary") } - synchronizeAllWebViews(excluding: primaryWebViewId, source: "anchorSecondary") + // During rapid geometry changes (e.g. divider drag), syncing every web view + // on every frame is expensive and causes stuttering. Each panel's + // HostContainerView fires its own geometry callback, so secondary web views + // will sync themselves. Defer the all-sync to coalesce with the next + // run-loop turn instead. scheduleDeferredFullSynchronizeAll() } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 2e401164..9f8dd05a 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -4313,6 +4313,7 @@ struct WebViewRepresentable: NSViewRepresentable { var onGeometryChanged: (() -> Void)? private(set) var geometryRevision: UInt64 = 0 private var lastReportedGeometryState: GeometryState? + private var hasPendingGeometryNotification = false private weak var hostedWebView: WKWebView? private var hostedWebViewConstraints: [NSLayoutConstraint] = [] private weak var localInlineSlotView: WindowBrowserSlotView? @@ -4647,7 +4648,30 @@ struct WebViewRepresentable: NSViewRepresentable { ) } + /// Record that geometry changed without firing the callback immediately. + /// `setFrameOrigin`/`setFrameSize` can fire multiple times before `layout()`; + /// deferring avoids redundant portal-sync cascades during divider drag. + /// A dispatch fallback ensures the callback fires even if `layout()` is not called. + /// Note: `lastReportedGeometryState` and `geometryRevision` are only updated + /// when the callback actually fires, so `updateNSView` sees a revision that + /// is strictly tied to emitted callbacks (no premature increments). + private func markGeometryDirtyIfNeeded() { + let state = currentGeometryState() + guard state != lastReportedGeometryState else { return } + guard !hasPendingGeometryNotification else { return } + hasPendingGeometryNotification = true + DispatchQueue.main.async { [weak self] in + self?.notifyGeometryChangedIfNeeded() + } + } + + /// Check for geometry changes and fire the callback. Also flushes any pending + /// dirty state from `markGeometryDirtyIfNeeded` so `layout()` supersedes the + /// async fallback. Only updates `lastReportedGeometryState` / `geometryRevision` + /// when the callback is emitted, keeping the revision in sync with actual + /// notifications. private func notifyGeometryChangedIfNeeded() { + hasPendingGeometryNotification = false let state = currentGeometryState() guard state != lastReportedGeometryState else { return } lastReportedGeometryState = state @@ -5070,7 +5094,8 @@ struct WebViewRepresentable: NSViewRepresentable { override func setFrameOrigin(_ newOrigin: NSPoint) { super.setFrameOrigin(newOrigin) window?.invalidateCursorRects(for: self) - notifyGeometryChangedIfNeeded() + // Mark dirty; the callback fires from layout() with the settled geometry. + markGeometryDirtyIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameOrigin") #endif @@ -5079,7 +5104,8 @@ struct WebViewRepresentable: NSViewRepresentable { override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) window?.invalidateCursorRects(for: self) - notifyGeometryChangedIfNeeded() + // Mark dirty; the callback fires from layout() with the settled geometry. + markGeometryDirtyIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameSize") #endif