mac: kill per-frame window-frame tracking (#899) (#904)
The macOS chat surface subscribed to volatile CGRects via `GeometryReader` + `onChange(of:)` in five places, so SwiftUI logged `onChange(of: CGRect) action tried to update multiple times per frame`. A `.global` window frame changes on every layout pass, and maximising/restoring a window (double-clicking the title bar) animates the geometry across many passes per displayed frame β so each closure fired several times per frame on the main thread. That's the Mac tail of the #580 iOS churn, surfaced by the window-resize repro in #899. Every consumer reads at tap/event/callback time, so none needs a continuously-observed value: - Media/YouTube-card + list-avatar lightbox anchors, the feed swipe-monitor pane frame, and the two window-drag grab zones (four `.global` trackers) now share the lazy `LiveFrame` anchor the iOS fix already uses: a passive backing view (now `NSView` on Mac, transparent to hit-testing) whose window frame is computed only when `.rect` is read. On AppKit the frame is flipped from bottom-left window coords to SwiftUI's top-left `.global` against the content view's height β exactly the flip the swipe / drag monitors already apply to their event points, so lightbox zoom origin, swipe routing and title-bar drag are unchanged. `LiveFrame` / `trackingFrame(into:)` are now `public` so the Mac target can share them. - The per-row feed-space frame probe (one per visible message row β the bulk of the warnings on resize) drops its `onChange(of: CGRect)` and instead writes the frame to the store as a direct side-effect during layout. FrameStore has no @Published (geometry writes must never invalidate a view), so the write is just as current, minus the observation. The container's `onChange(of: g.size)` viewport tracker is left as-is: it does real per-resize work (stage width), fires once per container not per row, and resizing content during the animation is expected (#899). No event-contract change; Mac + iOS both build.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
3 files changed,
+99
−74
ios/LChatCore/Sources/LChatCore/LiveFrame.swift+68 −41ios/LChatMac/NativeChatView.swift+14 −21ios/LChatMac/NativeFeedView.swift+17 −12
diff --git a/ios/LChatCore/Sources/LChatCore/LiveFrame.swift b/ios/LChatCore/Sources/LChatCore/LiveFrame.swift| index c52c0a0..5dc6f63 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/LiveFrame.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/LiveFrame.swift |
| @@ -1,61 +1,88 @@ |
| import SwiftUI |
| -#if canImport(UIKit) |
| +#if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| +import AppKit |
| +#else |
| import UIKit |
| #endif |
| -// π€ A card's live window frame, read at tap time β the lightbox zooms out |
| -// from it on both platforms (Mac: issue #580; iOS: the same zoom). |
| +// π€ A view's live window frame, read on demand β the lightbox zooms out from |
| +// it (Mac & iOS), and the Mac's swipe / window-drag monitors test hit points |
| +// against it. Every consumer reads at tap/event time; NOTHING needs a |
| +// continuously-updated value. |
| // |
| -// The frame must NOT be tracked with a GeometryReader + `onChange(of:)`: |
| -// that's a continuous observation of a value that changes on EVERY layout |
| -// pass β and a chat open runs several layout passes per displayed frame |
| -// (scroll-settle pin, avatar spring, composer inset, keyboard), so SwiftUI |
| -// flags "onChange(of: CGRect) action tried to update multiple times per |
| -// frame": real main-thread layout churn (a #580 stall suspect). Moving the |
| -// write off @State was NOT enough β the loop wasn't self-invalidation, it's |
| -// that we were observing a high-frequency value at all. |
| +// The frame must NOT be tracked with a GeometryReader + `onChange(of:)`: that's |
| +// a continuous observation of a value that changes on EVERY layout pass. A chat |
| +// open runs several passes per displayed frame (scroll-settle pin, avatar |
| +// spring, composer inset, keyboard) and β on macOS β a window maximise/restore |
| +// animates the window frame across many passes, so SwiftUI flags |
| +// "onChange(of: CGRect) action tried to update multiple times per frame": real |
| +// main-thread layout churn (the #580 stall on iOS; the #899 resize churn on |
| +// Mac). Moving the write off @State was NOT enough β the loop wasn't |
| +// self-invalidation, it's that we were observing a high-frequency value at all. |
| // |
| -// So on iOS nothing observes anything: a passive UIView sits in the card's |
| -// background and `rect` asks IT for its window frame only at the moment of |
| -// the tap. macOS keeps the GeometryReader tracker (AppKit backing views |
| -// aren't reachable the same way there, and the Mac feed never hit the churn). |
| -// The holder stays a plain reference so no write ever invalidates the view. |
| -// Only ever touched on the main actor (SwiftUI view code), hence @MainActor. |
| +// So nothing observes anything on either platform: a passive backing view sits |
| +// in the tracked view's background and `rect` asks IT for its window frame only |
| +// at the moment it's needed. The holder stays a plain reference so no write |
| +// ever invalidates the view. Only ever touched on the main actor (SwiftUI view |
| +// code / AppKit event monitors), hence @MainActor. |
| @MainActor |
| -final class LiveFrame { |
| - #if canImport(UIKit) |
| +public final class LiveFrame { |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + weak var anchor: NSView? |
| + /// The anchor's frame in SwiftUI `.global` space. AppKit window coords are |
| + /// bottom-left; SwiftUI `.global` is top-left β flip against the content |
| + /// view's height, exactly as the swipe / drag monitors flip their event |
| + /// points (`content.bounds.height - locationInWindow.y`). |
| + public var rect: CGRect { |
| + guard let anchor, let window = anchor.window else { return .zero } |
| + let w = anchor.convert(anchor.bounds, to: nil) // window base coords (bottom-left) |
| + let h = window.contentView?.bounds.height ?? window.frame.height |
| + return CGRect(x: w.minX, y: h - w.maxY, width: w.width, height: w.height) |
| + } |
| + #else |
| weak var anchor: UIView? |
| - var rect: CGRect { |
| + public var rect: CGRect { |
| guard let anchor, anchor.window != nil else { return .zero } |
| - return anchor.convert(anchor.bounds, to: nil) // window coords == SwiftUI .global |
| + return anchor.convert(anchor.bounds, to: nil) // window coords == SwiftUI .global (top-left) |
| } |
| - #else |
| - var rect: CGRect = .zero |
| #endif |
| + |
| + public init() {} |
| } |
| -extension View { |
| - /// Keep `holder.rect` answerable with this view's window frame. macOS |
| - /// tracks it reactively; iOS plants a passive anchor view and computes the |
| - /// frame lazily at tap time (no `onChange(of: CGRect)` layout churn β see |
| - /// LiveFrame). |
| +public extension View { |
| + /// Keep `holder.rect` answerable with this view's window frame, computed |
| + /// lazily at read time via a passive backing view β no `onChange(of: CGRect)` |
| + /// layout churn (see LiveFrame). |
| func trackingFrame(into holder: LiveFrame) -> some View { |
| - #if os(macOS) |
| - return background(GeometryReader { g in |
| - Color.clear |
| - .onAppear { holder.rect = g.frame(in: .global) } |
| - .onChange(of: g.frame(in: .global)) { _, f in holder.rect = f } |
| - }) |
| - #else |
| - return background(FrameAnchor(holder: holder)) |
| - #endif |
| + background(FrameAnchor(holder: holder)) |
| } |
| } |
| -#if canImport(UIKit) |
| -// The passive anchor: fills the card (as its background), never draws, never |
| -// participates in touch β exists only so LiveFrame can convert its bounds to |
| -// window coordinates on demand. |
| +// The passive anchor: fills the tracked view (as its background), never draws, |
| +// never participates in hit-testing β exists only so LiveFrame can convert its |
| +// bounds to window coordinates on demand. |
| +#if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| +private struct FrameAnchor: NSViewRepresentable { |
| + let holder: LiveFrame |
| + |
| + func makeNSView(context: Context) -> NSView { |
| + let v = PassiveView() |
| + holder.anchor = v |
| + return v |
| + } |
| + |
| + func updateNSView(_ v: NSView, context: Context) { |
| + holder.anchor = v |
| + } |
| + |
| + // Transparent to clicks so the tracked content (and anything beneath it) |
| + // keeps its taps β the same idiom as AnimatedGIFView's backing view. |
| + private final class PassiveView: NSView { |
| + override func hitTest(_ point: NSPoint) -> NSView? { nil } |
| + } |
| +} |
| +#else |
| private struct FrameAnchor: UIViewRepresentable { |
| let holder: LiveFrame |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index c61497d..f3431a1 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -46,9 +46,13 @@ struct NativeChatView: View { |
| // very top β extend the grab area over the whole chat header and the |
| // list's "Chats" row (down to the search box), like a real title bar. |
| // A box (not view state): the event-monitor closure must read live values. |
| + // The two grab-zone frames are LiveFrames β read lazily from a passive |
| + // backing view at mouse-event time, never tracked with a per-frame |
| + // `onChange(of: CGRect)` (that churned on every window resize β #899). |
| + @MainActor |
| private final class DragZones: ObservableObject { |
| - var feed: CGRect = .zero // the chat header row (global coords) |
| - var list: CGRect = .zero // the sidebar header row above search |
| + let feed = LiveFrame() // the chat header row |
| + let list = LiveFrame() // the sidebar header row above search |
| var down: NSEvent? // a press waiting to become a window drag |
| var isActive = true |
| } |
| @@ -69,7 +73,7 @@ struct NativeChatView: View { |
| case .leftMouseDown: |
| let p = event.locationInWindow |
| let point = CGPoint(x: p.x, y: content.bounds.height - p.y) |
| - guard zones.feed.contains(point) || zones.list.contains(point) else { return event } |
| + guard zones.feed.rect.contains(point) || zones.list.rect.contains(point) else { return event } |
| if event.clickCount == 2 { |
| window.zoom(nil) |
| return nil |
| @@ -90,15 +94,6 @@ struct NativeChatView: View { |
| } |
| } |
| - // Publishes a header row's window frame into the drag-zone box. |
| - private func dragZoneReporter(_ assign: @escaping (CGRect) -> Void) -> some View { |
| - GeometryReader { g in |
| - Color.clear |
| - .onAppear { assign(g.frame(in: .global)) } |
| - .onChange(of: g.frame(in: .global)) { _, f in assign(f) } |
| - } |
| - } |
| - |
| var body: some View { |
| HStack(spacing: 0) { |
| VStack(spacing: 0) { |
| @@ -651,7 +646,7 @@ struct NativeChatView: View { |
| // search box's top edge. |
| .frame(height: Self.headerHeight - 1) |
| // The header doubles as title bar: drag here moves the window. |
| - .background(dragZoneReporter { dragZones.feed = $0 }) |
| + .trackingFrame(into: dragZones.feed) |
| } |
| @State private var membersSheetThread: ChatThread? |
| @@ -918,7 +913,7 @@ struct NativeChatView: View { |
| .padding(.horizontal, 12) |
| .frame(height: Self.headerHeight) |
| // Doubles as title bar, down to the search box's top edge. |
| - .background(dragZoneReporter { dragZones.list = $0 }) |
| + .trackingFrame(into: dragZones.list) |
| // Thread search β like the web's, closed chats surface here too, |
| // so search is also how a closed chat gets found and reopened. |
| // Sits right under the header's border. |
| @@ -1364,7 +1359,9 @@ struct NativeChatView: View { |
| var onTap: ((PlatformImage, CGRect) -> Void)? |
| @State private var image: PlatformImage? |
| - @State private var globalFrame = CGRect.zero |
| + // The tap-time window frame for the lightbox zoom origin, read lazily |
| + // (no per-frame `onChange(of: CGRect)` churn β see LiveFrame / #899). |
| + @State private var liveFrame = LiveFrame() |
| var body: some View { |
| ZStack { |
| @@ -1397,17 +1394,13 @@ struct NativeChatView: View { |
| } |
| .frame(width: size, height: size) |
| .clipShape(Circle()) |
| - .background(GeometryReader { g in |
| - Color.clear |
| - .onAppear { globalFrame = g.frame(in: .global) } |
| - .onChange(of: g.frame(in: .global)) { _, f in globalFrame = f } |
| - }) |
| + .trackingFrame(into: liveFrame) |
| // No handler β the avatar is transparent to clicks, so the row / |
| // rail button underneath gets them (the gesture was swallowing |
| // every tap and made the collapsed rail completely dead). |
| .allowsHitTesting(onTap != nil) |
| .onTapGesture { |
| - if let onTap, let image { onTap(image, globalFrame) } |
| + if let onTap, let image { onTap(image, liveFrame.rect) } |
| } |
| .task(id: source?.blobID) { |
| guard let source else { return } |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 38b41c9..c9cd0e6 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -220,8 +220,10 @@ struct NativeFeedView: View { |
| // Deliberately NOT persisted β it's a peek, not a mode. |
| @State private var timesReveal: CGFloat = 0 |
| // The feed's window frame + the swipe monitor handle, for routing |
| - // horizontal scroll events (vertical ones pass through untouched). |
| - @State private var paneFrame: CGRect = .zero |
| + // horizontal scroll events (vertical ones pass through untouched). The |
| + // frame is a LiveFrame β read lazily at scroll-event time, never tracked |
| + // with a per-frame `onChange(of: CGRect)` (window-resize churn β #899). |
| + @State private var paneFrame = LiveFrame() |
| @State private var swipeMonitor: Any? |
| // Identifies this feed's registration on the shared drop router, so a |
| // stale unregister (a thread switch's old feed disappearing after the new |
| @@ -281,10 +283,17 @@ struct NativeFeedView: View { |
| // (jump-without-scroll). Writes to the frame store, |
| // never to state. |
| .background(GeometryReader { g in |
| - let f = g.frame(in: .named("feed")) |
| + // Direct side-effect write on every layout pass β |
| + // NOT `.onChange(of: CGRect)`. Observing this |
| + // per-frame value fired SwiftUI's "onChange(of: |
| + // CGRect) action tried to update multiple times |
| + // per frame" churn (Γevery visible row) during a |
| + // window maximise/restore (#899). The store has |
| + // no @Published, so the write invalidates nothing |
| + // (see FrameStore) β this keeps `rows` just as |
| + // current, minus the observation. |
| + let _ = (frames.rows[m.id] = g.frame(in: .named("feed"))) |
| Color.clear |
| - .onAppear { frames.rows[m.id] = f } |
| - .onChange(of: f) { _, f in frames.rows[m.id] = f } |
| .onDisappear { frames.rows.removeValue(forKey: m.id) } |
| }) |
| // Scroll-back: the oldest row materialising means the |
| @@ -513,12 +522,8 @@ struct NativeFeedView: View { |
| if let instance { await NativeFeedModelCache.shared.pollIntegrity(for: instance) } |
| } |
| // Track the feed's window frame so the swipe monitor only claims |
| - // scroll events that happen over it. |
| - .background(GeometryReader { g in |
| - Color.clear |
| - .onAppear { paneFrame = g.frame(in: .global) } |
| - .onChange(of: g.frame(in: .global)) { _, f in paneFrame = f } |
| - }) |
| + // scroll events that happen over it (read lazily β see paneFrame). |
| + .trackingFrame(into: paneFrame) |
| .onAppear { |
| model.start() // idempotent β a parked model is already live |
| frames.isActive = isActive |
| @@ -649,7 +654,7 @@ struct NativeFeedView: View { |
| // Window coords are bottom-left; SwiftUI's .global is top-left. |
| let p = event.locationInWindow |
| let point = CGPoint(x: p.x, y: content.bounds.height - p.y) |
| - guard paneFrame.contains(point) else { return event } |
| + guard paneFrame.rect.contains(point) else { return event } |
| // The release event arrives with ZERO deltas, so it must be |
| // handled before any horizontal-dominance check β that guard |