mac: thread rows skip re-render when nothing they draw changed (#1071) (#1073)
* mac: thread rows skip re-render when nothing they draw changed (#1071) The sidebar half of the render storm: per-pass Button/menu closures made every thread row unprovable-as-unchanged, so any store tick re-ran every row body (threadList → ThreadAvatar in the wedge samples). Equatable key = thread + selection + rendered time label; == short-circuits the body. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac: ThreadRowCell constructs lazily — a skipped row builds nothing Same amendment as FeedRow's: content becomes a closure so == skipping skips value construction, not just body evaluation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+29
−1
ios/LChatMac/NativeChatView.swift+29 −1
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index e53fb85..9278a2c 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -1223,7 +1223,11 @@ struct NativeChatView: View { |
| // one-click way back to everything. |
| if filtering { filterChip } |
| ForEach(visibleThreads) { thread in |
| - threadRow(thread) |
| + ThreadRowCell(key: ThreadRowKey(thread: thread, |
| + selected: thread.id == selectedID, |
| + timeLabel: thread.lastAt > 0 ? relTime(thread.lastAt) : ""), |
| + content: { AnyView(threadRow(thread)) }) |
| + .equatable() |
| } |
| // The funnel emptied the list — say so instead of presenting |
| // a silently blank pane. (Search's empty case is the panel's.) |
| @@ -1289,6 +1293,30 @@ struct NativeChatView: View { |
| store.reopenIfHidden(t) // opening a closed chat from search reopens it |
| } |
| + // The sidebar's half of issue #1071 (the ClickLab wedge samples split |
| + // between MessageBubble and threadList → ThreadAvatar): every store tick |
| + // re-ran every thread row's body, because the per-pass Button/menu |
| + // closures make rows unprovable-as-unchanged. The Equatable key lists |
| + // exactly what a row draws — the thread itself, the selection highlight, |
| + // and the rendered time label (so timestamps refresh exactly as often as |
| + // the list re-evaluates, same as before the gate). |
| + // ⚠️ Every render input must be listed — a field missing from the key |
| + // means its changes stop repainting rows. |
| + private struct ThreadRowKey: Equatable { |
| + let thread: ChatThread |
| + let selected: Bool |
| + let timeLabel: String |
| + } |
| + |
| + private struct ThreadRowCell: View, Equatable { |
| + let key: ThreadRowKey |
| + // Lazy for the same reason as FeedRow: a skipped row must construct |
| + // nothing, not just skip its body. |
| + let content: () -> AnyView |
| + static func == (a: Self, b: Self) -> Bool { a.key == b.key } |
| + var body: some View { content() } |
| + } |
| + |
| private func threadRow(_ t: ChatThread) -> some View { |
| Button { |
| selectThread(t) |