From 22b859dc7bf87e23e78e53b92ae3f963c5ad3077 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sat, 20 Jun 2026 17:56:29 +0300 Subject: [PATCH] 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 --- Jarvis/Views/Home/SignalFeedView.swift | 37 +++++++++++++++++--------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index f5de141..edc0043 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -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 = [] + /// 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()))?.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())) ?? [] + 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(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() +} + // Map a cached story back into a StorySummary for offline rendering. extension StorySummary { init(cached: CachedStory) {