native: bubble memo rotation + feed.mount span — fix the 1-3s thread opens (#1213)
The bubble caches (markdown trees, built AttributedStrings, first-link scans) evicted with removeAll() at 600 entries, so a long multi-space session wiped them constantly and the next thread open re-parsed every visible message on the main actor — measured live 2026-07-20 as 1-2s DM opens and ~3s on markdown-heavy bot threads (#1210). Memo replaces the wipe with two-generation rotation: a fill demotes the hot set and drops only the stalest generation; cold hits promote back. Also: a feed.mount span (onAppear → next main-queue turn) so re-opens of parked models — which fire no feed.openThread — finally show their cost in the ledger, and signpost details are marked public so trace exports stop redacting them to <private>.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+98
−19
ios/LChat/Views/NativeChatScreen.swift+8 −0ios/LChatCore/Sources/LChatCore/Memo.swift+37 −0ios/LChatCore/Sources/LChatCore/MessageBubble.swift+9 −18ios/LChatCore/Sources/LChatCore/Perf.swift+4 −1ios/LChatCore/Tests/LChatCoreTests/MemoTests.swift+40 −0
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 7230b0f..c916b8b 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -484,6 +484,14 @@ struct NativeChatScreen: View { |
| .sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) } |
| .sheet(isPresented: $showGroupApps) { GroupAppsSheet(model: model) } |
| .onAppear { |
| + // The mount cost — the "opening a thread hangs a little" |
| + // (#1210). feed.openThread only covers first loads; re-opens |
| + // attach a parked model and their whole cost is THIS mount. |
| + // Ending on the next main-queue turn brackets the first |
| + // render pass — an approximation, but one that made the 1–3s |
| + // opens visible at all. |
| + let mount = Perf.begin("feed.mount", slowMs: 300) |
| + DispatchQueue.main.async { mount.end("\(model.messages.count) messages") } |
| model.start() |
| LastOpenedChat.note(origin: instance.origin, thread: thread.id, |
| title: thread.title) |
diff --git a/ios/LChatCore/Sources/LChatCore/Memo.swift b/ios/LChatCore/Sources/LChatCore/Memo.swift| new file mode 100644 |
| index 0000000..59570d1 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/Memo.swift |
| @@ -0,0 +1,37 @@ |
| +import Foundation |
| + |
| +// 🤖 A two-generation memo: the fix for the bubble caches' `removeAll()` |
| +// eviction. The old crude cap wiped the ENTIRE memo whenever it filled, so |
| +// after a while of normal multi-space use every thread open re-parsed and |
| +// re-built every visible message on the main actor — measured on device |
| +// 2026-07-20 as 1–3s opens (worst on markdown-heavy bot threads; issue |
| +// #1210). Rotation keeps the hot set instead: when the hot generation |
| +// fills it becomes the cold one (dropping the previous cold — the actually |
| +// stale half), and a cold hit promotes back to hot. O(1), no timestamps, |
| +// worst-case memory two generations. |
| +struct Memo<Key: Hashable, Value> { |
| + private var hot: [Key: Value] = [:] |
| + private var cold: [Key: Value] = [:] |
| + private let cap: Int |
| + |
| + init(cap: Int = 600) { self.cap = cap } |
| + |
| + mutating func get(_ key: Key, _ build: () -> Value) -> Value { |
| + if let v = hot[key] { return v } |
| + if let v = cold[key] { |
| + promote(key, v) |
| + return v |
| + } |
| + let v = build() |
| + promote(key, v) |
| + return v |
| + } |
| + |
| + private mutating func promote(_ key: Key, _ v: Value) { |
| + if hot.count >= cap { |
| + cold = hot |
| + hot = [:] |
| + } |
| + hot[key] = v |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index 9736265..b0cae64 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -605,39 +605,30 @@ public struct MessageBubble: View { |
| let mine: Bool |
| let children: [MDInline] |
| } |
| - @MainActor private static var richMemo: [InlineKey: AttributedString] = [:] |
| - @MainActor private static var treeMemo: [String: [MDBlock]] = [:] |
| + // Two-generation memos (Memo.swift) — the old `removeAll()`-at-600 caps |
| + // wiped every cache whenever one filled, so a long session re-parsed |
| + // whole feeds on thread open (the 1–3s opens of #1210). |
| + @MainActor private static var richMemo = Memo<InlineKey, AttributedString>() |
| + @MainActor private static var treeMemo = Memo<String, [MDBlock]>() |
| @MainActor static func cachedTree(_ text: String) -> [MDBlock] { |
| - if let hit = treeMemo[text] { return hit } |
| - if treeMemo.count > 600 { treeMemo.removeAll() } // crude cap; parses are cheap to redo |
| - let parsed = markdownTree(text) |
| - treeMemo[text] = parsed |
| - return parsed |
| + treeMemo.get(text) { markdownTree(text) } |
| } |
| // firstLink re-runs the block + URL regex scan from scratch — memoised for |
| // the same reason as the parses above: it's called from every non-card |
| // bubble's body, on every publish and every row materialisation. |
| - @MainActor private static var linkMemo: [String: URL?] = [:] |
| + @MainActor private static var linkMemo = Memo<String, URL?>() |
| @MainActor static func cachedFirstLink(_ text: String) -> URL? { |
| - if let hit = linkMemo[text] { return hit } |
| - if linkMemo.count > 600 { linkMemo.removeAll() } |
| - let link = firstLink(text) |
| - linkMemo[text] = link |
| - return link |
| + linkMemo.get(text) { firstLink(text) } |
| } |
| @MainActor static func inlineText(_ children: [MDInline], ctx: MDContext) -> AttributedString { |
| // The built runs bake in \.chatFontSize, so it's part of the key — a |
| // ⌘+ / Text Size change must rebuild, not replay the old size. |
| let key = InlineKey(size: ctx.fontSize, mine: ctx.mine, children: children) |
| - if let hit = richMemo[key] { return hit } |
| - if richMemo.count > 600 { richMemo.removeAll() } |
| - let built = buildInline(children, ctx: ctx) |
| - richMemo[key] = built |
| - return built |
| + return richMemo.get(key) { buildInline(children, ctx: ctx) } |
| } |
| // Recursive, so nesting composes the way the web's element tree does: |
diff --git a/ios/LChatCore/Sources/LChatCore/Perf.swift b/ios/LChatCore/Sources/LChatCore/Perf.swift| index 6bb4302..0c43837 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Perf.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Perf.swift |
| @@ -121,7 +121,10 @@ public enum Perf { |
| private static func endSpan(_ span: Span, detail: String?, record: Bool) { |
| if let detail { |
| - signposter.endInterval(span.name, span.state, "\(detail)") |
| + // Public, or trace exports show "<private>" (learned from the |
| + // 2026-07-20 session). Details are paths/counts, never content, |
| + // and signposts never leave the device. |
| + signposter.endInterval(span.name, span.state, "\(detail, privacy: .public)") |
| } else { |
| signposter.endInterval(span.name, span.state) |
| } |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/MemoTests.swift b/ios/LChatCore/Tests/LChatCoreTests/MemoTests.swift| new file mode 100644 |
| index 0000000..7610ad9 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/MemoTests.swift |
| @@ -0,0 +1,40 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// 🤖 The two-generation memo's contract (#1210): a fill never wipes the hot |
| +// set (the removeAll() bug re-parsed whole feeds on thread open), cold hits |
| +// promote, and only the stalest generation is ever dropped. |
| +final class MemoTests: XCTestCase { |
| + func testHitsDoNotRebuild() { |
| + var memo = Memo<String, Int>(cap: 4) |
| + var builds = 0 |
| + XCTAssertEqual(memo.get("a") { builds += 1; return 1 }, 1) |
| + XCTAssertEqual(memo.get("a") { builds += 1; return 99 }, 1) |
| + XCTAssertEqual(builds, 1) |
| + } |
| + |
| + func testFillKeepsRecentEntriesReachable() { |
| + var memo = Memo<Int, Int>(cap: 2) |
| + var builds = 0 |
| + for k in 0..<4 { _ = memo.get(k) { builds += 1; return k } } |
| + XCTAssertEqual(builds, 4) |
| + // 2 and 3 straddle the last rotation (one hot, one cold) — both must |
| + // still be served without rebuilding. THIS is what removeAll() broke. |
| + _ = memo.get(3) { builds += 1; return -1 } |
| + _ = memo.get(2) { builds += 1; return -1 } |
| + XCTAssertEqual(builds, 4) |
| + } |
| + |
| + func testOnlyStalestGenerationDrops() { |
| + var memo = Memo<Int, Int>(cap: 2) |
| + var builds = 0 |
| + for k in 0..<2 { _ = memo.get(k) { builds += 1; return k } } // hot = {0,1} |
| + _ = memo.get(2) { builds += 1; return 2 } // rotate: cold={0,1}, hot={2} |
| + _ = memo.get(0) { builds += 1; return 0 } // cold hit → promoted, no rebuild |
| + XCTAssertEqual(builds, 3) |
| + // Fill until 1's generation is two rotations back — only THEN does it rebuild. |
| + for k in 10..<14 { _ = memo.get(k) { builds += 1; return k } } |
| + _ = memo.get(1) { builds += 1; return 1 } |
| + XCTAssertEqual(builds, 8) |
| + } |
| +} |