apollo-ios: ApolloPagination's loadNext throwing `loadInProgress` incorrectly
Summary
I have a forward, offset-based list using the library. onAppear of the last item of the list, it will trigger loadNext
to fetch more items. If I scroll the screen fast enough sometimes the loadNext
will not fetch more items and throw the error since isFetching
is true in paginationFetch
. From a user perspective, this causes the pagination to stop.
I believe the defer { isFetching = false }
in paginationFetch
is not executed promptly after watcher.refetch
finishes, as adding an additional line of isFetching = false
here fix the issue I’m facing.
Version
0.1.0
Steps to reproduce the behavior
See the summary
Logs
No response
Anything else?
No response
About this issue
- Original URL
- State: closed
- Created 4 months ago
- Reactions: 1
- Comments: 16 (10 by maintainers)
@mapy1874 I found this – which seems to be in spirit with what i would have built out as a package: https://github.com/danielsaidi/ScrollKit/blob/main/Sources/ScrollKit/Helpers/ScrollViewOffsetTracker.swift#L11-L35
I haven’t used it, so I can’t speak to how well it works – but it seems like what you’re looking for
@mapy1874 As a quick aside – there should be a clean workaround for
onAppear
usage in the next release, when callbacks tofetch
andrefetch
are added.In the near future, your
LaunchListViewModel
could look something like this (note that I didn’t implement this forrefetch
in this example – but should):(changes are: addition of
hasFirstPageLoaded
variable, adding a callback withinfetch()
)You could somewhat emulate this behavior today, by having the file look like this:
(Changes are: Addition of
hasFirstPageLoaded
variable, settinghasFirstPageLoaded
to false infetch()
, setting it totrue
insubscribe
ifsource
isfetch
)This would rely on a
task
picking up on theonChange
effect of afetch()
coming in (thehasFirstPageLoaded
variable changing), and firing its event again. A bit of an abuse of SwiftUI, but functional for folks relying ontask
of the last row. This should also work even in the event of cached results – as you showcased in your code sample above.I think the error being experienced at this point isn’t to do with
ApolloPagination
specifically – but rather,SwiftUI
’s default behavior.Stepping away from early invocation of
loadNextPage
for a moment – the issue you’re describing seems to boil down to the behavior of the.task
modifier, and could be triggered with any networking scheme – inclusive of the out-of-the-boxURLSession
’sdata(from:delegate:)
. The.task
modifier will trigger its closureonAppear
/onChange
and will cancel its asynchronous operationonDisappear
– so the moment you scroll off-screen, any activeTask
will be cancelled. SinceApolloPagination
’sGraphQLQueryPager
relies onTask
s under the hood, it respectsTask
cancellations – and will attempt to respond to cancellations.You could build in the behavior you’d prefer by:
task
, instead preferring to useonAppear
andonChange
.As far as invocating
loadNextPage
early – there are a few ways of doing that. For UIKit-based apps, you could create your ownInfiniteScrollController
that communicates with aUICollectionView
/UITableView
, hooks into thescrollViewWillEndDragging(_:withVelocity:targetContentOffset)
function to determine whether or not to fire off a load request. For a SwiftUI-based app, I would imagine you’d want some sort ofScrollViewReader
based solution.Ultimately, the question of what should trigger a load is somewhat philosophical. I’m of the opinion that it’s a user-triggered event. I fall into the camp of thinking that a specific row of data appearing on-screen isn’t a user-triggered event – but a side effect of the action the user took: scrolling. Given that, it makes sense to me that an infinite scroll load is triggered on scroll-based logic – especially given the default behaviors of the
onAppear
andtask
modifiers.As an aside, I think I’ve got an idea for a small
SwiftUI
package centered around providing that kind of behavior toList
. 🤔As a quick note on why we don’t allow fetches on cached data, since I don’t think I explained that in the previous message – we don’t allow fetches on cached data, since cached data may be stale. This is a common bug for folks that use a
returnCacheDataAndFetch
CachePolicy
– and I’ve seen it across a fair number of apps.@mapy1874 I took another look at your project – and realized that this could be rewritten in-place without relying on any changes in the pagination library:
https://github.com/mapy1874/loadInProgressBugDemo/compare/main...Iron-Ham:loadInProgressBugDemo:main
To summarize the changes made:
@Observable
instead ofObservedObject
.LaunchListView
changes:refreshable
, so we can pull-to-refresh. Notably, this goes through a different code-path than the initial fetch (it usesrefetch
under the hood).onAppear
totask
. Note: I’m usingtask
as it’s a multi-purpose function. It will trigger bothonAppear
andonChange
, as well as cancelonDisappear
. You may or may not want the cancellation behavioronDisappear
inherent totask
– and in the case you don’t, you can choose to instead implement bothonChange
andonAppear
.ProgressView()
.LaunchListViewModel
changes:canLoadNext
to be a calculated property.GraphQLQueryPager
, given that we are only seemingly interested in theLaunch
model of the results.subscribe(completion:)
instead ofreceive(on:).sink(receiveValue:)
. Really just a convenience thing, this isn’t a functional change.subscribe
call is just being used to setlaunches
orerror
, and nothing else.isLoading
toshowTailSpinner
, since that’s a more accurate representation of what’s happening. If we wanted to show a spinner on first load, a tail spinner likely isn’t the appropriate design choice.loadNextPage
, which ensures that we aren’t calling this when we shouldn’t.assertionFailure
: We will halt execution inDEBUG
if something is wrong. I didn’t hit this in my testing.My mental model of list view UIs
Everyone has a different mental model, and every application has unique constraints and requirements. With that said, my mental model for list view, generally speaking, is as follows:
nil
, it means we haven’t fetched our first page of values (and we don’t have any cached values) and should be showing the full screen loading page.refetch()
– which means you can make this anasync
function via a Continuation. That will allow you toawait
the function, and have the refresh control remain spinning until it is done fetching data.Let me know if that’s helpful as a demonstration!
Before diving in – I think that this change should allow you to set your
isLoading
value in the callbacks of bothfetch
andloadNext
– instead ofsink
. By default,loadNext
andloadPrevious
fetches aren’t performed with optimistic cache data, so that may be sufficient for your case?I intentionally didn’t allow for parallel execution for a few reasons:
loadNext
andloadPrevious
rely on the pager’s internal pagination state. That information isn’t committed to the internal value-set until an operation completes. That means that two simultaneousloadNext
operations would be fetching the same query, with the same arguments. Allowing for use of the cache-values opens up a can of worms with data consistency, as you could fetch data based on pagination-parameters that are no longer valid.loadPrevious
andloadNext
at the same time, but it didn’t seem worth the difficulty of implementing it given the niche use-case of a bi-directional pager.Queued fetches weren’t allowed for similar reasons:
loadNext
far more often than it should, all of these fetches would be queued, and the incorrect operation would be masked.AsyncGraphQLQueryPagerCoordinator
, which performs all of the pagination, technically does queue all of its messages by virtue of being anactor
. What it doesn’t do is ensure sequential execution; it will swap to the next message if the currently active message suspends execution. As I understand it (and I’m happy to be told otherwise!) that behavior violates a principal of Swift Concurrency, since it opens up the possibility of permanently suspending execution.reset
isn’t a big deal, but being able to determine when we should remove invalid queued operations is a bit harder. Let’s imagine we have 10 queuedloadNext
operation, and that after the currently executingloadNext
, we receive the final page of data. At that point, we’d have to look at all of the queued operations and manually removeloadNext
. If there is aloadPrevious
in the queue, that may still be a valid operation.fetch
andrefetch
would jump the queue by virtue of callingreset
prior to their execution.Effectively, what this translates into is an intentional design decision that it’s up to the caller to ensure that they aren’t calling
loadNext
andloadPrevious
more than they should be.In the context of GitHub, we do so by having an object which manages infinite-scroll behavior. At its core, it observes a list view’s scroll position and triggers a load when we are within a certain distance of the end of the list (e.g., when we are within
min(1.5*Vertical_Screen_Size, Some_Value)
we trigger a fetch). We’re primarily aUIKit
app, so our conventions aren’t identical to aSwiftUI
app – but the gist of how we’re using ApolloPagination for simple pagination tasks:loadNext
/loadPrevious
.fetch
, usually inviewDidLoad
.refetch
– as this guarantees that we show the user new data, and that the pull-to-refresh spinner appears, spins, and disappears when the new data comes in.I’ll take some some time this week and think on improvements to SwiftUI with an
onAppear
/task
scheme.task
encapsulatesonAppear
,onDisappear
, andonChange
– with cancellations being automatically forwarded as necessary.I really appreciate the feedback on this, it’s super valuable!
Totally, that makes sense to me. Adding additional docs around
AsyncGraphQLQueryPager
may be a good move here – as I can see that it’s not obvious that something like this is available.I think what’s hard about this assumption is that it’s not universally true. Imagine that you were fetching with
returnCacheDataAndFetch
instead of just fetching from the server. You could receive data twice: once from the cache, and then later from the server. When you receive data from the cache, you are still performing the fetch from the server. In that instance you’re not done until:GraphQLQueryPager
’sloadNext
’s callback firesAsyncGraphQLQueryPager
, you continue on from yourawait
.I’d have to think about how best to improve this – whether that’s documentation or API. 🤔
@Iron-Ham Thanks for your quick response!
For the need, I guess it’s less about the loading indicator. The bug was that no pagination would happen after we get the
loadInProgress
error given that we don’t have any retry mechanism. It’s about whether or not the pager is currently fetching in order to triggerloadNext
after the loading is finished.The assumption was that when we assign the new list and rendering the items on view, the library should have already done loading. Thus when the last item appears on the view, we are safe to trigger
loadNext
again.For your questions:
I do see people could get confused by this given it’s natural to assume when
GraphQLQueryPager
publisher emits events with data/error, the loading is complete.We started using ApolloPagination months ago, and there was no documentations back then and it seems natural for us to start with GraphQLQueryPager. Also, there are not that many materials on how to use
AsyncGraphQLQueryPager
in the official document. Thus, I assume people would default to use non-async oneHere’s a demo app to repro this if that helps: https://github.com/mapy1874/loadInProgressBugDemo
thanks for reporting! I’ll take a look at this momentarily.
@Iron-Ham 👀