I'm unable to animate changes to a UITableView when the underlying data changes (inserts/deletes) while using a query watcher. When I use a mutation elsewhere to insert/delete data from the table view, the table view and watcher query both get in a funky state because the local cache doesn't agree with my data I've modified on the server and stored in my posts instance variable as my datasource.
Here's some code from one of my UITableViewControllers for the watch query:
class PostListViewController: UITableViewController {
var watcher: GraphQLQueryWatcher<AllPostsQuery>?
var posts: [PostDetails]?
# snip
func loadData() {
watcher = apollo.watch(query: AllPostsQuery()) { (result, error) in
if let error = error {
print(#function, "ERROR | An error occured: \(error)")
return
}
guard let posts = result?.data?.posts else {
print(#function, "ERROR | Could not retrieve posts")
return
}
self.posts = posts.map { $0.fragments.postDetails }
self.tableView.reloadData()
}
}
}
And here is where I add a new Post:
let author = AuthorInput(firstName: newPost["firstName"]!, lastName: newPost["lastName"]!)
let createPostMutation = CreatePostWithAuthorMutation(
author: author,
title: newPost["title"]!
)
apollo.perform(mutation: createPostMutation) { (result, error) in
if let error = error {
print(#function, "ERROR | An error occured while adding the new Post: \(error)")
return
}
guard let newPost = result?.data?.createPostWithAuthor.fragments.postDetails else {
print(#function, "ERROR | Could not get the new Post")
return
}
print("Created new post: \(newPost)")
if let postsCount = self.posts?.count {
let indexPath = IndexPath(row: postsCount, section: 0)
self.posts?.append(newPost)
self.tableView.insertRows(at: [indexPath], with: .automatic)
}
What happens is the new row is correctly animated into the table like I want, but my watcher query doesn't work with the new row. I believe this doesn't work because the data cached for the AllPostsQuery is separate from the CreatePostWithAuthorMutation data (QUERY_ROOT vs MUTATION_ROOT).
If I remove my code to animate adding the row and instead call watcher.refetch(), all is right in the world again because the cache data for the AllPostsQuery is updated from the server and the table view gets completely reloaded instead of just animating in the new row.
I've perused documentation but I can't seem to find anything on updating cache for a particular query manually (which I believe would solve this issue).
Is there something I'm missing or another way to achieve what I'm trying to do? I'd like to be able to animate UI changes individually to my table views when data changes but still benefit from the query watcher updating the UI as well when the cache changes.
I'm happy to provide more of the code as necessary to reproduce/debug this issue.
Thanks for describing your issue in detail, it really helps to have concrete use cases in mind when discussing features.
I think there are two somewhat separate things going on here.
One has to do with the ability to update existing queries based on mutation results. The current version of Apollo iOS does have some primitives in place for updating the cache manually, similar to the ones in the JavaScript client (see updating the cache after a mutation). But these aren't documented yet because I'm working on revising the generated code and API. I hope to get a beta out this week, and then we can also improve the documentation to cover these use cases.
The other issue is animating changes to query results. Performing the animation from the mutation result handler is a bit brittle, because you're manually changing UI state. Instead, I think the best strategy here would be to animate changes based on updates from the query watcher. In order to do so, you need the ability to calculate the diff between the old and the new state. I haven't looked at this in detail yet, but we may be able to use something like Dwifft here. The benefit of setting animations up like this is that you never update the UI directly but always go through the store, an example of unidirectional data flow.
After submitting my issue, I had thought of diffing the data and properly animating the changes to the table view so it would seem great minds think alike :smile:.
The link you provided for updating the cache after a mutation is helpful and makes sense, but the code is for the javascript library and not the swift one. Is there a specific api for the swift library (even if it is undocumented) that I can use for manually changing the cache after a mutation?
@martijnwalraven I've done a little more digging into the internals of the framework and I think I have a semi-workable solution, however it would necessitate a change to the public api of the ApolloClient.
If a new method was exposed publicly to allow updating the cache, it can make for an easier way of keeping the watcher data in sync with mutations that occur.
public class ApolloClient {
// ...snip...
@discardableresult public func store(records: RecordSet) -> Promise<Void) {
return store.publish(records: records)
}
// ...snip...
}
I modified my GraphQL api to return not only the mutated object, but the entire list of objects after the mutation. The one pain point that this exposes is converting the data passed into a mutation's resultHandler closure. Creating a RecordSet from the returned data is a bit hairy code wise, but I've successfully made it so that I can keep my cached data in sync after mutations. I also can't take advantage of the cacheKeyForObject as my object isn't a JSONObject, but instead part of my fragments being used in my GraphQL query.
let newPosts = result?.data?.createPostWithAuthor.posts.flatMap {
$0.map { $0.fragments.postDetails }
}
if let newPosts = newPosts {
let referenceKeys = newPosts.map { Reference(key: "Post_\($0.id)") }
let recordSet = RecordSet(dictionaryLiteral: ("QUERY_ROOT", ["posts": referenceKeys]))
apollo.store(records: recordSet)
}
Since the mutation correctly adds/deletes to the overall cache, I opted to just update the QUERY_ROOT so that the watcher's closure get's triggered with the correct data.
This is really just some hacked together code, but it does achieve most of what I need along with the library you suggested (Dwifft) for the actual animations. Any thoughts?
Hey, sorry, I'm not at my computer right now, but you should be able to use the 'store.withinReadWriteTransaction' API to mutate the cache manually.
Would also like mutations to trigger watch updates as well :), fwiw
@martijnwalraven I'm trying to rewrite what I have to use the suggested store.withinReadWriteTransaction as it avoids some of the pitfalls and hackish code I mentioned, but I'm actually crashing Xcode with it. Does the call need to be wrapped in try await to work?
// Deleting a record from cache
apollo.perform(mutation: deletePostMutation) { result, error in
if let error = error {
NSLog("Error while deleting post: \(error.localizedDescription)")
return
}
guard let _ = result?.data?.deletePost.deletedPost.fragments.postDetails else {
print(#function, "ERROR | Could not get the deleted Post")
return
}
apollo.store { transaction in
try transaction.update(query: AllPostsQuery()) { (data: inout AllPostsQuery.Data) in
data.posts.remove(at: indexPath.row)
}
}
If I try and wrap the call in try await, it complains that await is an unresolved identifier.
Any help or direction would be greatly appreciated. Once I get this feeling pretty solid, I'd be more than happy to submit a pull request with my changes and some documentation surrounding the manual cache update.
await is not actually a keyword in Swift, but part of the promise implementation that Apollo iOS uses. I haven't tested this, but I believe something like this should work:
try apollo.store.withinReadWriteTransaction { transaction in
try transaction.update(query: AllPostsQuery()) { (data: inout AllPostsQuery.Data) in
data.posts.remove(at: indexPath.row)
}
}.await()
You may want to avoid using indexPath.row to identify the post to be removed, and use something more reliable like finding it by id.
It shouldn't be necessary to wait for the operation to complete however. What happens if you don't do that?
I figured await was part of a custom promise implementation as it was just proposed to be part of a future version of Swift by Chris Lattner.
For some reason, Xcode just stopped segfaulting when trying to compile the code. Not sure what was going on but I ran through the clean/build cycle several times and tried deleting all derived data as well. I modified my mutation to look like the following:
apollo.perform(mutation: deletePostMutation) { result, error in
if let error = error {
NSLog("Error while deleting post: \(error.localizedDescription)")
return
}
guard let _ = result?.data?.deletePost.deletedPost.fragments.postDetails else {
print(#function, "ERROR | Could not get the deleted Post")
return
}
let _ = apollo.store.withinReadWriteTransaction { transaction in
try transaction.update(query: AllPostsQuery()) { (data: inout AllPostsQuery.Data) in
data.posts?.remove(at: indexPath.row)
}
}
}
This is significantly easier than my earlier code to modify the data after performing a mutation, so thank you for the suggested method. However the data passed in to the transaction.update block is not accessible and results in EXC_BAD_ACCESS now. Any ideas? Thank you by the way for all of your help with this. I love the framework and I can't wait to get this sorted so I can start using it in my apps going forward!
@martijnwalraven I think I've finally got everything working as I would expect with mutations and updating the cache values. The following code for deletion and insertion works in my test application.
// deleting example
apollo.perform(mutation: deletePostMutation) { result, error in
if let error = error {
NSLog("Error while deleting post: \(error.localizedDescription)")
return
}
guard let _ = result?.data?.deletePost.deletedPost.fragments.postDetails else {
print(#function, "ERROR | Could not get the deleted Post")
return
}
let _ = apollo.store.withinReadWriteTransaction { transaction in
let query = AllPostsQuery()
try transaction.update(query: query) { (data) in
data.posts?.remove(at: indexPath.row)
}
self.watcher?.refetch()
}
}
// inserting example
apollo.perform(mutation: createPostMutation) { [weak self] (result, error) in
if let error = error {
print(#function, "ERROR | An error occured while adding the new Post: \(error)")
return
}
guard let newPost = result?.data?.createPostWithAuthor.newPost.fragments.postDetails else {
print(#function, "ERROR | Could not get the new Post")
return
}
let _ = apollo.store.withinReadWriteTransaction { transaction in
let query = AllPostsQuery()
try transaction.update(query: query) { (data) in
let post = AllPostsQuery.Data.Post(snapshot: newPost.snapshot)
let _ = data.posts?.append(post)
}
self?.watcher?.refetch()
}
self?.presentingViewController?.dismiss(animated: true)
}
As for the EXC_BAD_ACCESS, I was attempting to set breakpoints inside the transaction.update closure and check the value of the inout param being passed in and apparently that doesn't work properly with the debugger. However after the closure is called and execution is returned to the calling method, the data is properly mutated.
I would love to see this cache updating documented somehow in the official documentation for the library as it is incredibly handy to combine query watchers with mutations and properly update the cache.
Thank you again for all of your help and suggestions!
One last thing of note however, I believe this all depends on ApolloClient.store being publicly accessible. Since I've pulled the library in via CocoaPods, I've manually marked the instance variable as public for my own test application.
馃憤
I've been struggling with the same thing discussed here, and would love to see a straightforward resolution to this built into the framework.
@endoze the solution you posted above works, however, doesn't calling self?.watcher?.refetch() force a network request? Isn't the nice thing about using the watcher that you don't need to refetch from the network if the cache has been updated elsewhere (in the mutation result)? I have yet to get the watchers to work in the scenario you've described without forcing a network request. And, to me, that defeats the whole purpose of a watcher and I would just use a fetch instead.
Or am I missing something? I've just started playing around with the framework recently, so I'd love to know if I'm misunderstanding.
@andriajensen You're absolutely correct! If you mark fetch as public on the query watcher instead of just using refetch, you can specify the cache policy you prefer. I've verified that it works with self?.watcher?.fetch(cachePolicy: .returnCacheDataDontFetch).
With these minor tweaks to my proposed solution, you can update the local cache a query watcher uses and avoid network requests to update the results.
@martijnwalraven It looks like the two local modifications I have to make this all work is to mark the store property on ApolloClient as public as well as the fetch method on GraphQLQueryWatcher. With these two changes to the library, updating a query watcher's cache after a mutation is workable.
I have a similar use case, but don't have access to the watcher; I want the changes made in the withinReadWriteTransaction block to propagate to the watchers.
When using withinReadWriteTransaction I don't seem to be able to get the values to be saved to the cache, while using the cache directly does correctly update the values, but does not call any watchers.
For example, to set the rating of a MovieFragment I have the something similar to:
try! store.withinReadWriteTransaction({ transaction -> Void in
try transaction.updateObject(ofType: MovieFragment.self, withKey: movie.id, { (movie: inout MovieFragment) in
movie.rating = 5
})
}).await()
To get to this code I modified one of the tests that uses this function.
Doing this via the cache saves the value correctly, but does not notify watchers:
let updatedKeys: (String, Any) = ("rating", 5)
let record = Record(key: movie.id, Record.Fields(dictionaryLiteral: updatedKeys))
let recordSet = RecordSet(records: [record])
cache.merge(records: recordSet).andThen({ keys in
print("Updated keys:", keys)
}).catch({ error in
print("Whoops!", error)
})
Am I doing something wrong here?
We've recently made some substantial improvements to the cache and query watchers, including fixing some threading shenanigans. Would anyone who's had problems be willing to give version 0.38.0 a shot?