fix: synchronous optimistic read-state on article open
All checks were successful
Security Checks / dockerfile-lint (push) Successful in 4s
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s

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) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 22:13:56 -05:00
parent 415b125fb7
commit c0078adeb7

View File

@@ -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
}