From c0078adeb7c1a2bbd6020ee2f760c2cc6a28f22b Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Fri, 3 Apr 2026 22:13:56 -0500 Subject: [PATCH] fix: synchronous optimistic read-state on article open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local entries mutation now runs synchronously at the top of .task, before any Task {} or await: 1. vm.entries[idx].status = "read" ← synchronous, immediate 2. currentEntry syncs from mutated array ← immediate 3. Task { api.markEntries + getCounters } ← background 4. await getEntry ← content fetch, merge preserves local status Previously markAsRead was wrapped in Task {} (fire-and-forget), which SCHEDULED the mutation but didn't execute it until the main actor yielded. Line 2 read stale state because the mutation hadn't run yet. Now the @Observable array mutation happens before any async work, so the ForEach row re-renders on the same run loop — the list shows "read" even during the push animation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Features/Reader/Views/ArticleView.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift index 50977a0..aab286c 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift @@ -59,22 +59,35 @@ struct ArticleView: View { } } .task { - Task { await vm.markAsRead(entry) } + // 1. Synchronous local mutation — runs before any async work. + // The @Observable array mutation triggers ForEach row re-render + // immediately, so the list shows "read" even during push animation. + let entryId = entry.id + if let idx = vm.entries.firstIndex(where: { $0.id == entryId }), + !vm.entries[idx].isRead { + vm.entries[idx].status = "read" + } + currentEntry = vm.entries.first(where: { $0.id == entryId }) ?? currentEntry - if let updated = vm.entries.first(where: { $0.id == entry.id }) { - currentEntry = updated + // 2. API sync + counter refresh — background, fire-and-forget + Task { + let api = ReaderAPI() + try? await api.markEntries(ids: [entryId], status: "read") + vm.counters = try? await api.getCounters() } + // 3. Fetch full content — update when ready do { - let fullEntry = try await ReaderAPI().getEntry(id: entry.id) + let fullEntry = try await ReaderAPI().getEntry(id: entryId) let fullHTML = fullEntry.articleHTML if !fullHTML.isEmpty && fullHTML.count > articleContent.count { articleContent = fullHTML } + // Preserve local status/starred (may differ from server due to race) var merged = fullEntry - if let local = vm.entries.first(where: { $0.id == entry.id }) { + if let local = vm.entries.first(where: { $0.id == entryId }) { merged.status = local.status merged.starred = local.starred }