Skip to content

Commit a0c6b2c

Browse files
Prevent race condition caused by loading newer notification groups at the same time as older
Possible when very few notifications groups exist for this user, so the bottom loader is visible as soon as the notifications tab is opened.
1 parent 7502dae commit a0c6b2c

3 files changed

Lines changed: 140 additions & 79 deletions

File tree

Mastodon/In Progress New Layout and Datamodel/GroupedNotificationFeedLoader.swift

Lines changed: 114 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,18 @@ final public class GroupedNotificationFeedLoader {
2020
let canLoadOlder: Bool
2121
}
2222

23-
struct FeedLoadRequest: Equatable {
24-
let olderThan: String?
25-
let newerThan: String?
23+
public enum FeedLoadRequest {
24+
case older
25+
case newer
26+
case reload
2627

2728
var resultsInsertionPoint: InsertLocation {
28-
if olderThan != nil {
29+
switch self {
30+
case .older:
2931
return .end
30-
} else if newerThan != nil {
32+
case .newer:
3133
return .start
32-
} else {
34+
case .reload:
3335
return .replace
3436
}
3537
}
@@ -44,6 +46,8 @@ final public class GroupedNotificationFeedLoader {
4446
subsystem: "GroupedNotificationFeedLoader", category: "Data")
4547
private static let entryNotFoundMessage =
4648
"Failed to find suitable record. Depending on the context this might result in errors (data not being updated) or can be discarded (e.g. when there are mixed data sources where an entry might or might not exist)."
49+
50+
private var loadRequestQueue = [FeedLoadRequest]()
4751

4852
@Published private(set) var records: FeedLoadResult = FeedLoadResult(
4953
allRecords: [], canLoadOlder: true)
@@ -53,7 +57,15 @@ final public class GroupedNotificationFeedLoader {
5357

5458
private let timestampUpdater = TimestampUpdater(TimeInterval(30))
5559

56-
private var isFetching: Bool = false
60+
private var isFetching: Bool = false {
61+
didSet {
62+
if !isFetching, let waitingRequest = nextRequestThatCanBeLoadedNow() {
63+
Task {
64+
await load(waitingRequest)
65+
}
66+
}
67+
}
68+
}
5769

5870
public let useGroupedNotificationsApi: Bool
5971
private let cacheManager: (any NotificationsCacheManager)?
@@ -104,21 +116,18 @@ final public class GroupedNotificationFeedLoader {
104116
.$activeFilterBox
105117
.sink { filterBox in
106118
if filterBox != nil {
107-
Task { [weak self] in
108-
guard let self else { return }
109-
let curAllRecords = self.records.allRecords
110-
let curCanLoadOlder = self.records.canLoadOlder
111-
await self.replaceRecordsAfterFiltering(
112-
curAllRecords, canLoadOlder: curCanLoadOlder)
113-
}
119+
let curAllRecords = self.records.allRecords
120+
let curCanLoadOlder = self.records.canLoadOlder
121+
self.replaceRecordsAfterFiltering(
122+
curAllRecords, canLoadOlder: curCanLoadOlder)
114123
}
115124
}
116125
}
117126

118127
public func doFirstLoad() {
119128
Task {
120129
do {
121-
try await loadCached()
130+
try loadCached()
122131
} catch {
123132
}
124133
do {
@@ -128,18 +137,24 @@ final public class GroupedNotificationFeedLoader {
128137
}
129138
} catch {
130139
}
131-
await asyncLoadMore(olderThan: nil, newerThan: records.allRecords.first?.newestID)
140+
requestLoad(.newer)
132141
}
133142
}
134143

135144
public func commitToCache() async {
136145
await cacheManager?.commitToCache()
137146
}
147+
148+
private func noMoreResultsToFetch() {
149+
if records.canLoadOlder {
150+
records = FeedLoadResult(allRecords: records.allRecords, canLoadOlder: false)
151+
}
152+
}
138153

139-
private func replaceRecordsAfterFiltering(_ unfiltered: [NotificationRowViewModel], canLoadOlder: Bool? = nil) async {
154+
private func replaceRecordsAfterFiltering(_ unfiltered: [NotificationRowViewModel], canLoadOlder: Bool? = nil) {
140155
let filtered: [NotificationRowViewModel]
141156
if let filterBox = StatusFilterService.shared.activeFilterBox {
142-
filtered = await filter(unfiltered, forFeed: kind, with: filterBox)
157+
filtered = filter(unfiltered, forFeed: kind, with: filterBox)
143158
} else {
144159
filtered = unfiltered
145160
}
@@ -170,61 +185,105 @@ final public class GroupedNotificationFeedLoader {
170185
return deduped
171186
}
172187

173-
174-
public func asyncLoadMore(
175-
olderThan: String?,
176-
newerThan: String?
177-
) async {
178-
guard !isFetching else { return }
188+
private func loadCached() throws {
189+
guard !isFetching, let cacheManager else { return }
179190
isFetching = true
180191
defer {
181192
isFetching = false
182193
}
183-
let request = FeedLoadRequest(
184-
olderThan: olderThan, newerThan: newerThan)
185-
do {
186-
let newlyFetched = try await load(request)
187-
await updateAfterInserting(newlyFetchedResults: newlyFetched, at: request.resultsInsertionPoint)
188-
} catch {
189-
presentError?(error)
194+
let currentResults = cacheManager.currentResults()
195+
try replaceRecordsAfterFiltering(rowViewModels(from: currentResults), canLoadOlder: true)
196+
}
197+
198+
public func requestLoad(_ request: FeedLoadRequest) {
199+
if !loadRequestQueue.contains(request) {
200+
loadRequestQueue.append(request)
201+
}
202+
if let nextDoableRequest = nextRequestThatCanBeLoadedNow() {
203+
Task {
204+
await load(nextDoableRequest)
205+
}
190206
}
191207
}
192208

193-
private func loadCached() async throws {
194-
guard !isFetching, let cacheManager else { return }
195-
isFetching = true
196-
defer {
197-
isFetching = false
209+
public var permissionToLoadImmediately: Bool {
210+
// This is only intended for use with pull to refresh, in order to properly update the progress spinner.
211+
if isFetching {
212+
return false
213+
} else {
214+
isFetching = true
215+
return true
198216
}
199-
let currentResults = await cacheManager.currentResults()
200-
try await replaceRecordsAfterFiltering(rowViewModels(from: currentResults), canLoadOlder: true)
201217
}
202-
203-
private func load(_ request: FeedLoadRequest) async throws
204-
-> NotificationsResultType
218+
public func loadImmediately(_ request: FeedLoadRequest) async {
219+
// This is only intended for use with pull to refresh, in order to properly update the progress spinner.
220+
guard isFetching else { assertionFailure("request permissionToLoadImmediately before calling loadImmediately"); return }
221+
await load(request)
222+
}
223+
224+
private func nextRequestThatCanBeLoadedNow() -> FeedLoadRequest? {
225+
guard !isFetching else { return nil }
226+
guard !loadRequestQueue.isEmpty else { return nil }
227+
let nextRequest = loadRequestQueue.removeFirst()
228+
isFetching = true
229+
return nextRequest
230+
}
231+
232+
private func load(_ request: FeedLoadRequest) async
205233
{
206-
switch kind {
207-
case .notificationsAll:
208-
return try await loadNotifications(
209-
withScope: .everything, olderThan: request.olderThan, newerThan: request.newerThan)
210-
case .notificationsMentionsOnly:
211-
return try await loadNotifications(
212-
withScope: .mentions, olderThan: request.olderThan, newerThan: request.newerThan)
213-
case .notificationsWithAccount(let accountID):
214-
return try await loadNotifications(
215-
withAccountID: accountID, olderThan: request.olderThan, newerThan: request.newerThan)
234+
defer { isFetching = false }
235+
do {
236+
let olderThan: String?
237+
let newerThan: String?
238+
switch request {
239+
case .newer:
240+
olderThan = nil
241+
newerThan = records.allRecords.first?.newestID
242+
case .older:
243+
olderThan = records.allRecords.last?.oldestID
244+
newerThan = nil
245+
case .reload:
246+
olderThan = nil
247+
newerThan = nil
248+
}
249+
let results: NotificationsResultType
250+
switch kind {
251+
case .notificationsAll:
252+
results = try await loadNotifications(
253+
withScope: .everything, olderThan: olderThan, newerThan: newerThan)
254+
case .notificationsMentionsOnly:
255+
results = try await loadNotifications(
256+
withScope: .mentions, olderThan: olderThan, newerThan: newerThan)
257+
case .notificationsWithAccount(let accountID):
258+
results = try await loadNotifications(
259+
withAccountID: accountID, olderThan: olderThan, newerThan: newerThan)
260+
}
261+
updateAfterInserting(newlyFetchedResults: results, at: request.resultsInsertionPoint)
262+
} catch {
263+
presentError?(error)
216264
}
217265
}
218266
}
219267

220268
// MARK: - Filtering
221269
extension GroupedNotificationFeedLoader {
222270
private func updateAfterInserting(newlyFetchedResults: NotificationsResultType,
223-
at insertionPoint: GroupedNotificationFeedLoader.FeedLoadRequest.InsertLocation) async {
271+
at insertionPoint: GroupedNotificationFeedLoader.FeedLoadRequest.InsertLocation) {
272+
switch insertionPoint {
273+
case .start:
274+
guard newlyFetchedResults.hasContents else { return }
275+
case .replace:
276+
break
277+
case .end:
278+
guard newlyFetchedResults.hasContents else {
279+
noMoreResultsToFetch()
280+
return
281+
}
282+
}
224283
guard let cacheManager else { assertionFailure(); return }
225284
do {
226285
cacheManager.updateByInserting(newlyFetched: newlyFetchedResults, at: insertionPoint)
227-
let currentResults = await cacheManager.currentResults()
286+
let currentResults = cacheManager.currentResults()
228287
let unfiltered = try rowViewModels(from: currentResults)
229288

230289
let canLoadOlder: Bool? = {
@@ -238,7 +297,7 @@ extension GroupedNotificationFeedLoader {
238297
}
239298
}()
240299

241-
await replaceRecordsAfterFiltering(unfiltered, canLoadOlder: canLoadOlder)
300+
replaceRecordsAfterFiltering(unfiltered, canLoadOlder: canLoadOlder)
242301
} catch {
243302
presentError?(error)
244303
}
@@ -248,7 +307,7 @@ extension GroupedNotificationFeedLoader {
248307
_ records: [NotificationRowViewModel],
249308
forFeed feedKind: MastodonFeedKind,
250309
with filterBox: Mastodon.Entity.FilterBox
251-
) async -> [NotificationRowViewModel] {
310+
) -> [NotificationRowViewModel] {
252311
return records
253312
}
254313
}

Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,7 @@ struct NotificationListView: View {
146146
viewDidDisappear()
147147
}
148148
.accessibilityAction(named: L10n.Common.Controls.Actions.seeMore) {
149-
Task {
150-
await viewModel.refreshFeedFromTop()
151-
}
149+
viewModel.requestLoad(.newer)
152150
}
153151
}
154152
}
@@ -219,9 +217,7 @@ struct NotificationListView: View {
219217

220218
func viewDidAppear() {
221219
NotificationService.shared.clearNotificationCountForActiveUser()
222-
Task {
223-
await viewModel.refreshFeedFromTop()
224-
}
220+
viewModel.requestLoad(.newer)
225221
}
226222

227223
func viewDidDisappear() {
@@ -232,7 +228,7 @@ struct NotificationListView: View {
232228
}
233229

234230
func loadMore() {
235-
viewModel.loadOlder()
231+
viewModel.requestLoad(.older)
236232
}
237233

238234
func didTap(item: NotificationListItem) {
@@ -369,9 +365,7 @@ private class NotificationListViewModel: ObservableObject {
369365
notificationPolicyBannerRow
370366
+ withoutFilteredRow
371367

372-
Task {
373-
await feedLoader.asyncLoadMore(olderThan: nil, newerThan: nil)
374-
}
368+
feedLoader.requestLoad(.reload)
375369
}
376370

377371
func isUnread(_ item: NotificationListItem) -> Bool? {
@@ -436,17 +430,15 @@ private class NotificationListViewModel: ObservableObject {
436430
}
437431

438432
public func refreshFeedFromTop() async {
439-
let newestKnown = feedLoader.records.allRecords.first?.newestID
440-
await feedLoader.asyncLoadMore(olderThan: nil, newerThan: newestKnown)
441-
}
442-
443-
public func loadOlder() {
444-
let oldestKnown = feedLoader.records.allRecords.last?.oldestID
445-
Task {
446-
await feedLoader.asyncLoadMore(olderThan: oldestKnown, newerThan: nil)
433+
if feedLoader.permissionToLoadImmediately {
434+
await feedLoader.loadImmediately(.newer)
447435
}
448436
}
449437

438+
public func requestLoad(_ loadRequest: GroupedNotificationFeedLoader.FeedLoadRequest) {
439+
feedLoader.requestLoad(loadRequest)
440+
}
441+
450442
public func commitToCache() async {
451443
await feedLoader.commitToCache()
452444
}

Mastodon/In Progress New Layout and Datamodel/NotificationsCacheManager.swift

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import MastodonCore
77
protocol NotificationsCacheManager<T> {
88
associatedtype T: NotificationsResultType
99

10-
func currentResults() async -> T?
10+
func currentResults() -> T?
1111
var currentLastReadMarker: LastReadMarkers.MarkerPosition? { get }
1212
var mostRecentlyFetchedResults: T? { get }
1313
func updateByInserting(newlyFetched: NotificationsResultType, at insertionPoint: GroupedNotificationFeedLoader.FeedLoadRequest.InsertLocation)
@@ -16,9 +16,19 @@ protocol NotificationsCacheManager<T> {
1616
func commitToCache() async
1717
}
1818

19-
protocol NotificationsResultType {}
20-
extension Mastodon.Entity.GroupedNotificationsResults: NotificationsResultType {}
21-
extension Array<Mastodon.Entity.Notification>: NotificationsResultType {}
19+
protocol NotificationsResultType {
20+
var hasContents: Bool { get }
21+
}
22+
extension Mastodon.Entity.GroupedNotificationsResults: NotificationsResultType {
23+
var hasContents: Bool {
24+
return notificationGroups.isNotEmpty
25+
}
26+
}
27+
extension Array<Mastodon.Entity.Notification>: NotificationsResultType {
28+
var hasContents: Bool {
29+
return isNotEmpty
30+
}
31+
}
2232

2333
@MainActor
2434
class UngroupedNotificationCacheManager: NotificationsCacheManager {
@@ -41,7 +51,7 @@ class UngroupedNotificationCacheManager: NotificationsCacheManager {
4151
self.mostRecentMarkers = nil
4252
}
4353

44-
func currentResults() async -> T? {
54+
func currentResults() -> T? {
4555
if let mostRecentlyFetchedResults {
4656
return mostRecentlyFetchedResults
4757
} else if let staleResults {
@@ -273,7 +283,7 @@ class GroupedNotificationCacheManager: NotificationsCacheManager {
273283
mostRecentMarkers = updatable
274284
}
275285

276-
func currentResults() async -> T? {
286+
func currentResults() -> T? {
277287
if let mostRecentlyFetchedResults {
278288
return mostRecentlyFetchedResults
279289
} else if let staleResults {

0 commit comments

Comments
 (0)