From 73da164daab4f2e202076b9930c0ffaa5ad4e836 Mon Sep 17 00:00:00 2001 From: kevinsslin Date: Fri, 12 Jun 2026 03:30:51 -0700 Subject: [PATCH] Fix 100% CPU render loop when an extension sidebar is selected (#5970) VerticalTabsSidebar observed workspace state with `.onReceive` on a Combine publisher rebuilt inline on every body pass, so SwiftUI re-subscribed on every render. Each publisher is a CombineLatest of @Published fields that replays its current value on subscription; that replay bumped the @State extensionSidebarUpdateToken, re-invalidated the body, and re-subscribed again: an unbounded re-render loop that pinned the main thread at ~100% CPU whenever a non-default extension sidebar (e.g. the bundled "Project Worktrees" preset) was selected. The selection persists, so the app re-entered the loop on every launch. Observe via `.task(id: renderContext.tabIds)` instead, bridging the existing observation publishers through `.values`. SwiftUI now owns the subscription lifecycle: it restarts only when the observed workspace set changes and is torn down on disappear, so a subscription is established once per genuine change rather than every frame. No new Combine view state and no shadow tab-id tracker. The publishers are unchanged, so their on-subscribe replay (relied on by a late-mounting sidebar row, covered by WorkspaceSidebarObservationTests) still works. --- Sources/ContentView.swift | 54 ++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 1ebf08a926ad..ab53a6eccf07 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11414,18 +11414,26 @@ struct VerticalTabsSidebar: View { CmuxExtensionSidebarSelection.setProviderId(CmuxSidebarProviderDescriptor.defaultWorkspacesID) } ) - .onReceive( - extensionSidebarImmediateObservationPublisher(renderContext: renderContext) - .receive(on: RunLoop.main) - ) { _ in - refreshExtensionSidebarSnapshot() + // Observe workspace state via `.task(id:)` so SwiftUI owns the + // subscription lifecycle: restarted only when the observed workspace + // set changes, torn down on disappear. Bridging the Combine + // publishers through `.values` (instead of `.onReceive` on a publisher + // rebuilt every body pass) avoids re-subscribing on every render, + // which replayed each `@Published`-backed publisher's current value, + // bumped `extensionSidebarUpdateToken`, re-invalidated the body, and + // span the main thread at 100% CPU once an extension sidebar was + // selected (#5970). + .task(id: renderContext.tabIds) { + for await _ in extensionSidebarImmediateObservationPublisher(renderContext: renderContext).values { + refreshExtensionSidebarSnapshot() + } } - .onReceive( - extensionSidebarDebouncedObservationPublisher(renderContext: renderContext) - .receive(on: RunLoop.main) + .task(id: renderContext.tabIds) { + let debounced = extensionSidebarDebouncedObservationPublisher(renderContext: renderContext) .debounce(for: Self.extensionSidebarObservationCoalesceInterval, scheduler: RunLoop.main) - ) { _ in - refreshExtensionSidebarSnapshot() + for await _ in debounced.values { + refreshExtensionSidebarSnapshot() + } } // Fade the extension's content out at the bottom so it dissolves behind the // sidebar footer instead of overlapping it sharply, matching the default @@ -11586,18 +11594,22 @@ struct VerticalTabsSidebar: View { } .background(Color.clear) .modifier(ClearScrollBackground()) - .onReceive( - extensionSidebarImmediateObservationPublisher(renderContext: renderContext) - .receive(on: RunLoop.main) - ) { _ in - refreshExtensionSidebarSnapshot() + // Stable observation via `.task(id:)` (see the matching block above): + // SwiftUI restarts it only when the observed workspace set changes, so + // the publishers are not rebuilt and re-subscribed every body pass, + // which replayed their current value and span the main thread at 100% + // CPU once an extension sidebar was selected (#5970). + .task(id: renderContext.tabIds) { + for await _ in extensionSidebarImmediateObservationPublisher(renderContext: renderContext).values { + refreshExtensionSidebarSnapshot() + } } - .onReceive( - extensionSidebarDebouncedObservationPublisher(renderContext: renderContext) - .receive(on: RunLoop.main) - .debounce(for: Self.extensionSidebarObservationCoalesceInterval, scheduler: RunLoop.main) - ) { _ in - refreshExtensionSidebarSnapshot() + .task(id: renderContext.tabIds) { + let debounced = extensionSidebarDebouncedObservationPublisher(renderContext: renderContext) + .debounce(for: Self.extensionSidebarObservationCoalesceInterval, scheduler: RunLoop.main) + for await _ in debounced.values { + refreshExtensionSidebarSnapshot() + } } .onReceive( NotificationCenter.default.publisher(for: BrowserStackSidebar.stateDidLoadNotification)