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 cachedArticles: [CachedArticle]
|
||||||
@Query private var readStories: [ReadStory]
|
@Query private var readStories: [ReadStory]
|
||||||
@Query private var savedStories: [SavedStory]
|
@Query private var savedStories: [SavedStory]
|
||||||
@Query private var seenStories: [SeenStory]
|
|
||||||
@State private var showFeeds = false
|
@State private var showFeeds = false
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
@State private var selectedPill: StoryPill = .all
|
@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
|
/// Snapshot of already-seen story ids, captured per load so the order stays
|
||||||
/// stable while scrolling (and refreshes to sink newly-seen ones).
|
/// stable while scrolling (and refreshes to sink newly-seen ones).
|
||||||
@State private var seenSnapshot: Set<String> = []
|
@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
|
@State private var showRead = false
|
||||||
|
|
||||||
/// Stories that have full article content cached → eligible for the green dot.
|
/// 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) }
|
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) {
|
private func markSeen(_ s: StorySummary) {
|
||||||
let id = s.id
|
guard seenTracker.ids.insert(s.id).inserted else { return }
|
||||||
guard !seenStories.contains(where: { $0.id == id }) else { return }
|
modelContext.insert(SeenStory(id: s.id))
|
||||||
modelContext.insert(SeenStory(id: id))
|
|
||||||
try? modelContext.save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func markUnread(_ s: StorySummary) {
|
private func markUnread(_ s: StorySummary) {
|
||||||
@@ -459,24 +464,32 @@ struct SignalFeedView: View {
|
|||||||
// MARK: - Caching
|
// MARK: - Caching
|
||||||
|
|
||||||
private func cacheStories(_ stories: [StorySummary]) {
|
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 {
|
for summary in stories {
|
||||||
let id = summary.id
|
if let found = byId[summary.id] {
|
||||||
let existing = try? modelContext.fetch(
|
|
||||||
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })
|
|
||||||
)
|
|
||||||
if let found = existing?.first {
|
|
||||||
found.signalScore = summary.signalScore
|
found.signalScore = summary.signalScore
|
||||||
found.sourceCount = summary.sourceCount
|
found.sourceCount = summary.sourceCount
|
||||||
found.tags = summary.tags ?? []
|
found.tags = summary.tags ?? []
|
||||||
found.updatedAt = summary.updatedAt
|
found.updatedAt = summary.updatedAt
|
||||||
} else {
|
} else {
|
||||||
modelContext.insert(CachedStory(from: summary))
|
let c = CachedStory(from: summary)
|
||||||
|
modelContext.insert(c)
|
||||||
|
byId[summary.id] = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try? modelContext.save()
|
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.
|
// Map a cached story back into a StorySummary for offline rendering.
|
||||||
extension StorySummary {
|
extension StorySummary {
|
||||||
init(cached: CachedStory) {
|
init(cached: CachedStory) {
|
||||||
|
|||||||
Reference in New Issue
Block a user