eleven / ocho Public

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>
Dermot Daly 4dcb40a5acc85a9631e4e4f919a2f51d4c273a63 parent 6a1a60b
3 files changed, +99 −74
  • ios/LChatCore/Sources/LChatCore/LiveFrame.swift +68 −41
  • ios/LChatMac/NativeChatView.swift +14 −21
  • ios/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