eleven / messenger Public

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>
Paul Campbell 285526f85909eae658ff96b61e289fe34cc6cb52 parent 415bb30
5 files changed, +98 −19
  • ios/LChat/Views/NativeChatScreen.swift +8 −0
  • ios/LChatCore/Sources/LChatCore/Memo.swift +37 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +9 −18
  • ios/LChatCore/Sources/LChatCore/Perf.swift +4 −1
  • ios/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)
+ }
+}