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>
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 { |