Fix feed jank: make seen-tracking and caching cheap
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled

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:
Robin Kutesa
2026-06-20 17:56:29 +03:00
parent 798f46fbb1
commit 22b859dc7b

View File

@@ -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) {