eleven / messenger Public

mac: feed rows skip re-render when nothing they draw changed (#1071) (#1072)

* mac: feed rows skip re-render when nothing they draw changed (#1071)

Every model tick (typing, sender names, status) re-ran every visible
MessageBubble body — SwiftUI can't prove rows unchanged when each pass
hands the bubble ~nine fresh closures. FeedRow gates each row behind an
Equatable key listing exactly what the row draws (message + previous for
grouping, muted fold, sender name, canEdit, theme, stage geometry,
video-download state); == short-circuits the body. The wedge samples
behind the 1-5s activation freezes fingerprinted exactly this path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: FeedRow constructs lazily — a skipped row builds nothing

Wedge sample 214800 (post-gate): per-pass VALUE construction of every
materialized row (bubble + closures + actions, then AnyView boxing)
still burned main-thread time at -Onone. Content becomes a closure the
body calls — == skipping now skips construction too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 619f0b91e56783ea73990808f32326255ae9bfc0 parent dfe05d7
1 file changed, +51 −1
  • ios/LChatMac/NativeFeedView.swift +51 −1
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 45a9144..e2405b5 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -314,7 +314,10 @@ struct NativeFeedView: View {
.frame(maxWidth: .infinity)
}
ForEach(Array(model.messages.enumerated()), id: \.element.id) { i, m in
- row(m, previous: i > 0 ? model.messages[i - 1] : nil, proxy: proxy)
+ let previous = i > 0 ? model.messages[i - 1] : nil
+ FeedRow(key: rowKey(m, previous: previous),
+ content: { AnyView(row(m, previous: previous, proxy: proxy)) })
+ .equatable()
.id(m.id)
// Deleted messages implode — a quick shrink-and-
// fade where the bubble stood.
@@ -935,6 +938,53 @@ struct NativeFeedView: View {
// Bubble row with run-grouping, mirroring the web feed: the sender name
// shows once per run (groups only), and bubbles inside a run sit tighter
// than bubbles that start one.
+ // One Equatable key per feed row. SwiftUI can't prove our rows unchanged
+ // on its own — each body pass hands MessageBubble ~nine fresh closures —
+ // so ANY model tick (someone typing, a sender name arriving, status
+ // flips) re-ran every visible bubble's body: seconds of main-thread
+ // render work around activation (issue #1071, the "hangs when I load
+ // in" bug; ClickLab wedge samples fingerprinted bubbleRow/MessageBubble).
+ // FeedRow compares THIS key instead and skips the body when nothing the
+ // row draws has changed.
+ // ⚠️ Every render input must be listed — a field missing from the key
+ // means its changes stop repainting rows.
+ private struct FeedRowKey: Equatable {
+ let message: ChatFeedModel.Message
+ let previous: ChatFeedModel.Message? // grouping: separator + same-sender run
+ let mutedFold: Bool
+ let senderName: String
+ let canEdit: Bool // messageActions' one state read
+ let isGroup: Bool
+ let themeName: String
+ let stageWidth: CGFloat
+ let timesReveal: CGFloat
+ let downloadingID: String?
+ let downloadingProgress: Double?
+ }
+
+ private struct FeedRow: View, Equatable {
+ let key: FeedRowKey
+ // A CLOSURE, not an eager AnyView: building the row value (bubble +
+ // nine closures + actions) per pass costs real time at -Onone even
+ // when == then skips the body — lazy content means a skipped row
+ // constructs nothing at all.
+ let content: () -> AnyView
+ static func == (a: Self, b: Self) -> Bool { a.key == b.key }
+ var body: some View { content() }
+ }
+
+ private func rowKey(_ m: ChatFeedModel.Message,
+ previous: ChatFeedModel.Message?) -> FeedRowKey {
+ FeedRowKey(message: m, previous: previous,
+ mutedFold: model.foldsAsMuted(m),
+ senderName: model.senderName(m),
+ canEdit: model.canEdit(m),
+ isGroup: isGroup, themeName: themeName,
+ stageWidth: stageWidth, timesReveal: timesReveal,
+ downloadingID: videoDownload?.id,
+ downloadingProgress: videoDownload?.progress)
+ }
+
@ViewBuilder
private func row(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?,
proxy: ScrollViewProxy) -> some View {