Skip to content

Commit 9e90725

Browse files
committed
fix(chat): stop sizing probes from freezing the chat feed
Opening a busy channel could block the main thread for minutes: stack layout probes every row at several candidate widths, the probes multiply through the nested stacks, and each probe re-rendered the live NSTextView and hashed the whole message for its cache lookups. With 180 mounted rows that one layout pass never finished. A sizing probe is now pure and O(1) on repeat: it never touches the live text view (the view renders once, in setFrameSize, at the width that won), and each view memoises its built text and measured size per rounded width, so a probe is a small dictionary hit instead of a full-text hash through the global caches. Claude-Session: https://claude.ai/code/session_01PKetCunJwQPgV9SHoJ3i5q
1 parent 77969b6 commit 9e90725

2 files changed

Lines changed: 62 additions & 12 deletions

File tree

‎Sources/KanbanCode/SelectableMarkdownText.swift‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ struct SelectableMarkdownText: NSViewRepresentable {
9393
private var lineLimit: Int?
9494
private var renderedWidth: CGFloat = 0
9595

96+
/// Per-width memos for this view's own text. Stack layout negotiates a
97+
/// row's width by probing it at many candidate widths, and the probes
98+
/// multiply through nested stacks: answering each one through the
99+
/// global caches means hashing the whole message per call, which a
100+
/// long feed turns into a multi-minute layout pass. A view holds one
101+
/// message, so keying by width alone makes a repeat probe a plain
102+
/// dictionary hit.
103+
private var attributedByWidth: [CGFloat: NSAttributedString] = [:]
104+
private var sizeByWidth: [CGFloat: CGSize] = [:]
105+
/// A live width drag can visit many widths; past this the older
106+
/// entries are worthless, so the memo starts over.
107+
private static let widthMemoLimit = 12
108+
96109
func configure(
97110
content: ChatTextContent,
98111
appearance: ChatTextAppearance,
@@ -112,6 +125,8 @@ struct SelectableMarkdownText: NSViewRepresentable {
112125
if changed {
113126
self.renderedWidth = 0
114127
self.reportedOverflow = nil
128+
self.attributedByWidth.removeAll(keepingCapacity: true)
129+
self.sizeByWidth.removeAll(keepingCapacity: true)
115130
}
116131
self.textContainer?.maximumNumberOfLines = lineLimit ?? 0
117132
self.textContainer?.lineBreakMode = lineLimit == nil ? .byWordWrapping : .byTruncatingTail
@@ -145,29 +160,47 @@ struct SelectableMarkdownText: NSViewRepresentable {
145160
NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric)
146161
}
147162

163+
/// A pure measurement: it must never touch the live text view. This is
164+
/// the answer to a sizing probe, and stack layout probes a row many
165+
/// times per pass at widths it will not use — rendering here re-lays
166+
/// the text out per probe and turns one pass into minutes. The view
167+
/// itself is rendered from `setFrameSize`, at the width that won.
148168
func fittingSize(forWidth width: CGFloat) -> CGSize {
149-
self.render(width: width)
169+
let width = width.rounded()
170+
if let cached = self.sizeByWidth[width] { return cached }
150171
let attributed = self.attributedText(width: width)
151172
let size = ChatTextMeasurement.size(
152173
of: attributed, width: width, lineLimit: self.lineLimit)
153-
guard self.content.shrinksToFit else {
154-
return CGSize(width: width, height: size.height)
155-
}
156174
// Report the text's own width, not the whole proposal. A short row
157175
// that claims the full width cannot be centred by its container,
158176
// which is how system notices are laid out, and it stretches a user
159177
// bubble across the whole column.
160-
return CGSize(width: min(width, size.width), height: size.height)
178+
let fitting = self.content.shrinksToFit
179+
? CGSize(width: min(width, size.width), height: size.height)
180+
: CGSize(width: width, height: size.height)
181+
if self.sizeByWidth.count >= Self.widthMemoLimit {
182+
self.sizeByWidth.removeAll(keepingCapacity: true)
183+
}
184+
self.sizeByWidth[width] = fitting
185+
return fitting
161186
}
162187

163188
private func attributedText(width: CGFloat) -> NSAttributedString {
164-
ChatAttributedText.make(
189+
let width = width.rounded()
190+
if let cached = self.attributedByWidth[width] { return cached }
191+
let built = ChatAttributedText.make(
165192
content: self.content, appearance: self.textAppearance,
166193
highlight: self.highlight, links: self.links, width: width
167194
)
195+
if self.attributedByWidth.count >= Self.widthMemoLimit {
196+
self.attributedByWidth.removeAll(keepingCapacity: true)
197+
}
198+
self.attributedByWidth[width] = built
199+
return built
168200
}
169201

170202
private func render(width: CGFloat) {
203+
let width = width.rounded()
171204
guard width > 1, abs(width - self.renderedWidth) > 0.5 else { return }
172205
self.renderedWidth = width
173206
self.textContainer?.size = CGSize(

‎Tests/KanbanCodeTests/SelectableMarkdownTextTests.swift‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@ struct SelectableMarkdownTextTests {
3333
}
3434

3535
/// The view is configured before SwiftUI has given it a frame, so its first
36-
/// `configure` runs at width zero. Table columns are sized against the
37-
/// available width, so rendering at that point collapses every column to a
38-
/// single character and draws the message over its neighbours.
39-
@Test("renders at the proposed width even before it has a frame")
40-
func rendersAtProposedWidthWithoutAFrame() {
36+
/// `configure` runs at width zero. Sizing probes measure off screen and
37+
/// leave the view alone — a probe width is usually not the width the row
38+
/// ends up with — so the columns take their shape when the frame lands.
39+
@Test("measures at the proposed width and renders when the frame lands")
40+
func rendersWhenTheFrameLands() {
4141
let view = SelectableMarkdownText.WrappingTextView.make()
4242
view.configure(
4343
content: .markdown(Self.tableMarkdown), appearance: Self.appearance, highlight: nil)
@@ -47,6 +47,7 @@ struct SelectableMarkdownTextTests {
4747
#expect(size.width > 200)
4848
#expect(size.height > 40)
4949

50+
view.setFrameSize(NSSize(width: 690, height: size.height))
5051
let origins = Self.columnOrigins(view)
5152
#expect(origins.count == 3)
5253
if origins.count == 3 {
@@ -56,6 +57,22 @@ struct SelectableMarkdownTextTests {
5657
}
5758
}
5859

60+
/// Stack layout probes a row at many candidate widths per pass, and the
61+
/// probes multiply through nested stacks. A probe that re-lays the live
62+
/// text view out each time turns a long feed's layout pass into minutes of
63+
/// blocked main thread, so measuring must leave the view alone.
64+
@Test("a sizing probe does not render the live view")
65+
func sizingProbeLeavesTheViewAlone() {
66+
let view = SelectableMarkdownText.WrappingTextView.make()
67+
view.configure(
68+
content: .markdown(Self.tableMarkdown), appearance: Self.appearance, highlight: nil)
69+
70+
for width in [300, 500, 690, 500, 300] {
71+
_ = view.fittingSize(forWidth: CGFloat(width))
72+
}
73+
#expect(view.textStorage?.length == 0)
74+
}
75+
5976
/// System notices sit inside a centring `HStack`. A row that reports the
6077
/// full proposed width leaves the spacers nothing to take, so it drifts to
6178
/// the leading edge instead of staying centred.
@@ -93,7 +110,7 @@ struct SelectableMarkdownTextTests {
93110
"""
94111
let view = SelectableMarkdownText.WrappingTextView.make()
95112
view.configure(content: .markdown(wideTable), appearance: Self.appearance, highlight: nil)
96-
_ = view.fittingSize(forWidth: 400)
113+
view.setFrameSize(NSSize(width: 400, height: 400))
97114
let narrow = Self.columnOrigins(view)
98115

99116
view.setFrameSize(NSSize(width: 900, height: 400))

0 commit comments

Comments
 (0)