Fix feed jank: make seen-tracking and caching cheap
The "All" feed stuttered because every row appearing ran markSeen, which did a disk save(), an O(n) contains over a @Query'd SeenStory array, and triggered a full feed re-render on each insert. Now markSeen does an O(1) insert into a non-observed reference Set, no per-row save (the container autosaves), and SeenStory is no longer @Query'd by the view. cacheStories also does one fetch + an in-memory index instead of a query per story. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,6 @@ struct SignalFeedView: View {
|
||||
@Query private var cachedArticles: [CachedArticle]
|
||||
@Query private var readStories: [ReadStory]
|
||||
@Query private var savedStories: [SavedStory]
|
||||
@Query private var seenStories: [SeenStory]
|
||||
@State private var showFeeds = false
|
||||
@State private var showSettings = false
|
||||
@State private var selectedPill: StoryPill = .all
|
||||
@@ -33,6 +32,8 @@ struct SignalFeedView: View {
|
||||
/// Snapshot of already-seen story ids, captured per load so the order stays
|
||||
/// stable while scrolling (and refreshes to sink newly-seen ones).
|
||||
@State private var seenSnapshot: Set<String> = []
|
||||
/// Reference-type set so marking a story seen on scroll doesn't re-render.
|
||||
@State private var seenTracker = SeenTracker()
|
||||
@State private var showRead = false
|
||||
|
||||
/// Stories that have full article content cached → eligible for the green dot.
|
||||
@@ -67,13 +68,17 @@ struct SignalFeedView: View {
|
||||
filteredStories.filter { readStoryIds.contains($0.id) }
|
||||
}
|
||||
|
||||
private func captureSeenSnapshot() { seenSnapshot = Set(seenStories.map(\.id)) }
|
||||
private func captureSeenSnapshot() {
|
||||
let ids = (try? modelContext.fetch(FetchDescriptor<SeenStory>()))?.map(\.id) ?? []
|
||||
seenSnapshot = Set(ids)
|
||||
seenTracker.ids = seenSnapshot
|
||||
}
|
||||
|
||||
/// Hot path — runs as each row scrolls in. O(1), no disk write, no re-render:
|
||||
/// the container autosaves, and SeenStory isn't @Query'd by this view.
|
||||
private func markSeen(_ s: StorySummary) {
|
||||
let id = s.id
|
||||
guard !seenStories.contains(where: { $0.id == id }) else { return }
|
||||
modelContext.insert(SeenStory(id: id))
|
||||
try? modelContext.save()
|
||||
guard seenTracker.ids.insert(s.id).inserted else { return }
|
||||
modelContext.insert(SeenStory(id: s.id))
|
||||
}
|
||||
|
||||
private func markUnread(_ s: StorySummary) {
|
||||
@@ -459,24 +464,32 @@ struct SignalFeedView: View {
|
||||
// MARK: - Caching
|
||||
|
||||
private func cacheStories(_ stories: [StorySummary]) {
|
||||
guard !stories.isEmpty else { return }
|
||||
// One fetch + an in-memory index, instead of a query per story.
|
||||
let existing = (try? modelContext.fetch(FetchDescriptor<CachedStory>())) ?? []
|
||||
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
for summary in stories {
|
||||
let id = summary.id
|
||||
let existing = try? modelContext.fetch(
|
||||
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })
|
||||
)
|
||||
if let found = existing?.first {
|
||||
if let found = byId[summary.id] {
|
||||
found.signalScore = summary.signalScore
|
||||
found.sourceCount = summary.sourceCount
|
||||
found.tags = summary.tags ?? []
|
||||
found.updatedAt = summary.updatedAt
|
||||
} else {
|
||||
modelContext.insert(CachedStory(from: summary))
|
||||
let c = CachedStory(from: summary)
|
||||
modelContext.insert(c)
|
||||
byId[summary.id] = c
|
||||
}
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
/// Session-seen ids in a reference type — mutating it doesn't trigger SwiftUI
|
||||
/// updates, so marking rows seen on scroll stays cheap.
|
||||
private final class SeenTracker {
|
||||
var ids = Set<String>()
|
||||
}
|
||||
|
||||
// Map a cached story back into a StorySummary for offline rendering.
|
||||
extension StorySummary {
|
||||
init(cached: CachedStory) {
|
||||
|
||||
Reference in New Issue
Block a user