Fence the instance browser's first load, not only its next page

Pagination carried the login it was requested for, but the load that
fills the tab in the first place did not. Its subscriptions, feed and
playlist requests all resolve well after the credential behind them can
have been replaced, and cancelling the previous task does not settle it:
a request that already came back races the cancellation and installs the
previous person's private videos over the purge that was meant to remove
them. Popular and trending stay unfenced — they are unauthenticated, and
discarding them on a change of login would cost a fetch for nothing.

Discarding the account-scoped content now also forgets how far the feed
had been read. The high-water mark belongs to the feed that was thrown
away; left behind, it gates pagination against a page the next login
never loaded, and that login cannot ask for a second page until its
first exceeds a count it knows nothing about.
This commit is contained in:
Arkadiusz Fal
2026-08-29 20:21:08 +02:00
parent 5dd4bcb48b
commit 361f19b762

View File

@@ -1208,6 +1208,11 @@ struct InstanceBrowseView: View {
feedPage = 1 feedPage = 1
hasMoreFeedResults = false hasMoreFeedResults = false
isLoadingMoreFeed = false isLoadingMoreFeed = false
// The high-water mark belongs to the discarded feed. Left behind, it
// gates pagination against the previous login's count: the next one
// would have to exceed a page it never loaded before it could ask for
// a second.
feedLoadedVideoCount = 0
} }
private func refreshLoginState() { private func refreshLoginState() {
@@ -1253,6 +1258,13 @@ struct InstanceBrowseView: View {
isLoading = !hasData // Only show loading spinner when no existing data isLoading = !hasData // Only show loading spinner when no existing data
errorMessage = nil errorMessage = nil
// Which login the account-scoped tabs are being loaded for. Cancelling
// the previous task is not enough on its own: a request that already
// came back races the cancellation, and its results would be installed
// after the purge that was meant to remove them. Popular and trending
// are unauthenticated and deliberately not fenced.
let generation = accountContentGeneration
do { do {
switch selectedTab { switch selectedTab {
case .popular: case .popular:
@@ -1280,6 +1292,7 @@ struct InstanceBrowseView: View {
// Load subscriptions and feed based on instance type // Load subscriptions and feed based on instance type
let subscriptionChannels: [Channel] let subscriptionChannels: [Channel]
let videos: [Video] let videos: [Video]
let hasMore: Bool
switch instance.type { switch instance.type {
case .invidious: case .invidious:
@@ -1302,7 +1315,7 @@ struct InstanceBrowseView: View {
subscriptionChannels = enrichedSubscriptions.map { $0.toChannel(baseURL: instance.url) } subscriptionChannels = enrichedSubscriptions.map { $0.toChannel(baseURL: instance.url) }
videos = feedResponse.videos videos = feedResponse.videos
hasMoreFeedResults = feedResponse.hasMore hasMore = feedResponse.hasMore
case .piped: case .piped:
let api = PipedAPI(httpClient: appEnvironment.httpClient) let api = PipedAPI(httpClient: appEnvironment.httpClient)
@@ -1316,7 +1329,7 @@ struct InstanceBrowseView: View {
subscriptionChannels = pipedSubscriptions.map { $0.toChannel() } subscriptionChannels = pipedSubscriptions.map { $0.toChannel() }
videos = feedVideos videos = feedVideos
// Piped doesn't support pagination for feed // Piped doesn't support pagination for feed
hasMoreFeedResults = false hasMore = false
default: default:
errorMessage = String(localized: "feed.error.notSupported") errorMessage = String(localized: "feed.error.notSupported")
@@ -1324,6 +1337,9 @@ struct InstanceBrowseView: View {
return return
} }
guard generation == accountContentGeneration else { return }
hasMoreFeedResults = hasMore
feedSubscriptions = subscriptionChannels.map { $0.enrichedThumbnail(using: appEnvironment.dataManager) } feedSubscriptions = subscriptionChannels.map { $0.enrichedThumbnail(using: appEnvironment.dataManager) }
feedVideos = videos feedVideos = videos
prefetchBranding(for: videos) prefetchBranding(for: videos)
@@ -1343,6 +1359,9 @@ struct InstanceBrowseView: View {
let api = InvidiousAPI(httpClient: appEnvironment.httpClient) let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
let playlists = try await api.userPlaylists(instance: instance, sid: credential) let playlists = try await api.userPlaylists(instance: instance, sid: credential)
guard generation == accountContentGeneration else { return }
userPlaylists = playlists userPlaylists = playlists
} }
} catch is CancellationError { } catch is CancellationError {