Files
hapi/ios/Packages/HapiKit/Sources/HapiUI/Markdown/MarkdownRenderCache.swift
T
weishu c2e3d16b7e feat(native): improve anchored chat scrolling and history loading
Add viewport-driven paging with layout acknowledgements, bounded retries, cancellation gates, and epoch-safe history retention.

Preserve transcript anchors and expansion state, fix tool-group identity collisions, and serialize Android history coordination on Main.

Reduce per-scroll composition and layout work; add native regression tests, CI coverage, and profiling guidance.
2026-09-10 16:22:36 +08:00

101 lines
3.6 KiB
Swift

import Foundation
import SwiftUI
/// Per-chat bounded cache. Synchronous lookup avoids an actor-hop placeholder
/// when a cell is recycled; preparation runs off the main thread.
public final class MarkdownRenderCache: @unchecked Sendable {
private struct Entry {
let blocks: [MarkdownBlockNode]
let cost: Int
var accessed: Int
}
private let lock = NSLock()
private var entries: [String: Entry] = [:]
private var clock = 0
private var totalCost = 0
private let maxCost: Int
public init(maxCost: Int = 8 * 1024 * 1024) { self.maxCost = maxCost }
public func cached(_ text: String) -> [MarkdownBlockNode]? {
lock.withLock {
guard var entry = entries[text] else { return nil }
clock += 1
entry.accessed = clock
entries[text] = entry
return entry.blocks
}
}
public func prepare(_ sources: [String]) async {
let task = Task.detached(priority: .userInitiated) { [self] in
for text in Set(sources) where cached(text) == nil {
guard !Task.isCancelled else { return }
guard text.utf8.count * 4 <= maxCost else { continue }
let blocks = MarkdownBlockTree.build(from: text)
lock.withLock {
guard entries[text] == nil else { return }
clock += 1
let cost = max(1, text.utf8.count * 4)
guard cost <= maxCost else { return }
entries[text] = Entry(blocks: blocks, cost: cost, accessed: clock)
totalCost += cost
while totalCost > maxCost || entries.count > 800 {
guard let oldest = entries.min(by: { $0.value.accessed < $1.value.accessed }) else { break }
totalCost -= oldest.value.cost
entries.removeValue(forKey: oldest.key)
}
}
}
}
await withTaskCancellationHandler {
await task.value
} onCancel: {
task.cancel()
}
}
}
private struct MarkdownRenderCacheKey: EnvironmentKey {
static let defaultValue: MarkdownRenderCache? = nil
}
public extension EnvironmentValues {
var hapiMarkdownCache: MarkdownRenderCache? {
get { self[MarkdownRenderCacheKey.self] }
set { self[MarkdownRenderCacheKey.self] = newValue }
}
}
/// Normally prewarmed before publishing the chat snapshot. An on-demand
/// miss keeps previous content until the new revision has been parsed.
public struct CachedMarkdownView: View {
public let markdown: String
@Environment(\.hapiMarkdownCache) private var cache
@State private var prepared: [MarkdownBlockNode]?
public init(markdown: String) { self.markdown = markdown }
public var body: some View {
Group {
if let cache {
if let blocks = cache.cached(markdown) ?? prepared { MarkdownView(blocks: blocks) }
else { Text(markdown).textSelection(.enabled) }
} else {
MarkdownView(markdown: markdown)
}
}
.task(id: markdown) {
guard let cache else { return }
await cache.prepare([markdown])
guard !Task.isCancelled else { return }
if let blocks = cache.cached(markdown) { prepared = blocks }
else {
let source = markdown
let blocks = await Task.detached { MarkdownBlockTree.build(from: source) }.value
if !Task.isCancelled { prepared = blocks }
}
}
}
}