From b9170492435c64aec47938af1ead032f85eae9ca Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 14 Sep 2026 09:02:32 +0200 Subject: [PATCH] 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. --- Yattee/Services/SubscriptionFeedCache.swift | 37 +++++++++++++++------ 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/Yattee/Services/SubscriptionFeedCache.swift b/Yattee/Services/SubscriptionFeedCache.swift index 780afef6..eeae542a 100644 --- a/Yattee/Services/SubscriptionFeedCache.swift +++ b/Yattee/Services/SubscriptionFeedCache.swift @@ -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 }