Keep a fenced feed refresh from stranding the loading flag

The refresh captured its account generation before hopping onto the
detached task it runs in. That hop is a suspension point: a context
handler running in the gap - the wake-time status flap bumps the
generation there - left the request carrying a stale generation. Its
completion was then fenced out, and the fenced return deliberately
leaves isLoading alone, so nothing current owned the flag: it stayed
true, every later refresh skipped itself as already loading, and the
retry paths only watch for errors - the feed sat on a spinner nothing
would ever finish.

The generation and context are now read at dispatch time, so the
request that claims isLoading always carries the generation current at
that moment and its completion is the one that releases it. The
stateless-server poller's terminal writes get the same fence: a stale
poller must not hand a newer request's spinner an early ready.
This commit is contained in:
Arkadiusz Fal
2026-09-14 09:02:32 +02:00
parent a28346148a
commit b917049243

View File

@@ -389,21 +389,33 @@ final class SubscriptionFeedCache {
return
}
let generation = beginRequest()
// The *effective* context, so an account still waiting on its instance
// or credential falls back to local rather than reporting
// notAuthenticated while the subscriptions list already shows local data.
let context = appEnvironment.dataContextValidator.effectiveContext
let backend = appEnvironment.feedBackend(for: context)
LoggingService.shared.debug("Starting feed refresh for \(backend)", category: .general)
// Use a detached task to prevent SwiftUI from cancelling the request
// when @State properties change during the refresh operation.
// This is necessary because loadSubscriptionsAsync() updates @State
// before feedCache.refresh() completes, which can cause SwiftUI to
// cancel the parent task.
//
// The generation and context are read *inside* the detached body, not
// before it: the hop onto it is a suspension point, and a context
// handler running in that gap bumps the generation and resets
// `isLoading`. A request dispatched with the pre-hop generation is
// fenced out on arrival, and the fenced return deliberately leaves
// `isLoading` alone — with nothing current owning it, the flag stayed
// true and every later refresh was skipped as "already loading",
// leaving a spinner nothing would ever finish. Reading at dispatch
// time means the request that sets `isLoading` always carries the
// generation that is current at that moment, so its completion is the
// one that resets it.
await Task.detached { @MainActor in
let generation = self.beginRequest()
// The *effective* context, so an account still waiting on its instance
// or credential falls back to local rather than reporting
// notAuthenticated while the subscriptions list already shows local data.
let context = appEnvironment.dataContextValidator.effectiveContext
let backend = appEnvironment.feedBackend(for: context)
LoggingService.shared.debug("Starting feed refresh for \(backend)", category: .general)
switch backend {
case .local:
await self.refreshLocalAccount(using: appEnvironment, generation: generation)
@@ -783,6 +795,7 @@ final class SubscriptionFeedCache {
appEnvironment.dataManager.updateLastVideoPublishedDates(from: serverVideos)
prefetchDeArrow(for: serverVideos, using: appEnvironment)
}
guard generation == accountGeneration else { return }
feedLoadState = .ready
isLoading = false
return
@@ -818,7 +831,11 @@ final class SubscriptionFeedCache {
)
}
// Show whatever we have
// Show whatever we have — unless the login this poller was working for
// has been fenced out meanwhile: then the state belongs to whatever
// request the switch started, and a stale ".ready" here would end its
// spinner early or let a duplicate refresh through.
guard generation == accountGeneration else { return }
feedLoadState = .ready
isLoading = false
}