aerogear / offix

GraphQL Offline Client and Server
https://offix.dev
Apache License 2.0
758 stars 45 forks source link

Example of how to restore offline changes #340

Closed aarshaw closed 3 years ago

aarshaw commented 4 years ago

Struggling with how to restore offline changes so that they reflect on the UI.

The following offline mutation saves the data to the offline store and reflects immediately on the UI - however when I refresh the page the changes are no longer reflected.

const [ updateTodo ] = useOfflineMutation(gql`
  mutation UpdateTodo ($id: ID! $name: String!)
    updateTodo (id: $id name: $id) {
      id
      name
    }
  }
`)

await updateTodo({
  variables: { id: item.id, name: item.name },
  returnType: 'Todo',
  operationType: CacheOperation.REFRESH,
  idField: 'id'
})

I've read through the docs and came across mutationCacheUpdates which looks as if it's designed to do the trick but I can't work out for the life of me how to use it - any pointers/examples would be much appreciated - cheers!

darahayes commented 4 years ago

Hey there, thanks for logging an issue. When you first perform the offline mutation, you will see the changes exactly as you described. To provide a little explanation - what's happening is a generic update function and optimisticResponse are being applied to the Apollo Cache. The update function tells the client how your changes should land in the cache. (e.g. you updated something, or created something, etc)

Normally you'd have to specify an update function yourself (see the docs on optimistic responses) but offix-client has some cleverness that can generate a function for you based on the CacheOperation.REFRESH argument you provided.

The problem with optimistic responses is that they are not persisted to cache storage so when we refresh/restart the app, they are no longer present. So they need to be rebuilt again. To be able to do that, we need those update functions again. That's the purpose of mutationCacheUpdates so you're on the right track!

The mutationCacheUpdates is an object that you pass into your client that tells the client what kind of CacheOperation a given mutation is and what queries it affects. (for example: addTodo would be CacheOperation.ADD and it would affect your getTodos query.)

Staying with your todo example your mutationCacheUpdates might look like this:

import { CacheOperation, getUpdateFunction } from 'offix-cache'

export const todoCacheUpdates = {
  createTodo: getUpdateFunction({
    mutationName: 'addTodo',
    idField: 'id',
    operationType: CacheOperation.ADD,
    updateQuery: getTodos
  }),
  updateTodo: getUpdateFunction({
    mutationName: 'updateTodo',
    idField: 'id',
    operationType: CacheOperation.REFRESH,
    updateQuery: getTodos
  }),
  deleteTodo: getUpdateFunction({
    mutationName: 'deleteTodo',
    idField: 'id',
    operationType: CacheOperation.DELETE,
    updateQuery: getTodos
  })
};

const client = new OfflineClient({
  //your config
  mutationCacheUpdates: todoCacheUpdates
})

Please let me know if that helps!

darahayes commented 4 years ago

The getUpdateFunction is a helper that returns you a generic cache update function that will work for simple enough cases like creating, updating and removing items in a list but when you have more advanced cases you'll need to write your own update function that you'd supply in your mutation and in the mutationCacheUpdates also.

Hopefully that makes sense! I think we have some of this documented but it's probably confusing and missing some details. I'll make sure we get it updated ASAP. If you have any more questions or issues we're more than happy to help!

bdbch commented 4 years ago

@darahayes thanks for the explanation. Lets say I have a mutation which sends a message to a specific chatId and the update function needs this chatId to manually write it into the cache - how do I specify this chatId in the cacheUpdateFunctions? Or are they automatically restored from the cached mutations and will be passed to the specified update function whenever the mutation will run?

bdbch commented 4 years ago

Okay nevermind, I'm having it in the chatId in the response. So I've configured the update function as a global update function like that:

const cacheUpdates = {
  SEND_MESSAGE: sendMessageUpdate,
}

export const client = new ApolloClient({
  cache,
  cacheStorage,
  link,
  mutationCacheUpdates: cacheUpdates,
  networkStatus,
  offlineStorage: cacheStorage,
})

where sendMessageUpdate is the update function I previously used to update my cache locally from the mutation call itself.

My mutation now looks like this:

client
  .offlineMutate({
    mutation: SEND_MESSAGE,
    optimisticResponse,
    update: client.mutationCacheUpdates?.SEND_MESSAGE,
    variables: {
      chatId,
      clientGeneratedUUID: messageUUID,
      forwardedMessages: [],
      images: mappedImages,
      message,
    },
  })

The optimistic response works when I'm online and offline but restoring the optimistic UI won't work after app restart. Am I missing something?

darahayes commented 4 years ago

It would be good to know how you are writing things to the cache with your update function. I recommend you have a quick glance at the Apollo docs on update functions because they can be pretty tricky.

Update functions get passed two params cache and result. You can and should reference those instead of any variables in the wider scope. (I'm conscious of the example code you mentioned in your other issue which referenced a chatId variable that seemed to come from nowhere). Your update function should probably look something like this.

client.offlineMutate({
  ...
  update: (cache, result) => {
    // get a reference to the message and its properties like chatId
    const message = result.data.sendMessage 
    writeMessageToCache(cache, message)
  }
  ...
})
bdbch commented 4 years ago

Hey! Thanks for the quick answer. I already refactored my code so I can get the chatId from inside my mutations result result.data.sendMessage.chat.id

My update function looks similar to the basic example from the Apollo docs. What I'm basically doing is readying my GET_CHAT_MESSAGES query with readQuery with the correct chatId, then append or update the message (depends on the update) and then update my query with writeQuery.

Here is my code:

export const sendMessageUpdate = (proxy: DataProxy, resp: any) => {
  const chatId = resp.data.sendMessage[0].chat.id
  const data: any = proxy.readQuery({
    query: GET_CHAT_MESSAGES,
    variables: {
      chatId,
      offset: new Date().getTimezoneOffset() / 60 / -1,
    },
  })

  const hasMessage = data.messages.some(
    (message: any) =>
      resp.data.sendMessage[0] && message.id === resp.data.sendMessage[0].id,
  )

  if (!hasMessage) {
    proxy.writeQuery({
      data: {
        ...data,
        messages: [resp.data.sendMessage[0], ...data.messages],
      },
      query: GET_CHAT_MESSAGES,
      variables: {
        chatId,
        offset: new Date().getTimezoneOffset() / 60 / -1,
      },
    })
  }
}

Thanks for helping me with this one. I'm struggling with our offline support for a few weeks now as it's a pretty tricky thing to handle.

darahayes commented 4 years ago

@bdbch sorry I couldn't get back to you sooner, I understand the struggle this stuff is pretty tricky. On the face of it your update function looks good to me and it's promising that it works when offline/online.

I'm wondering is there some reason your update function isn't being called on startup. Can you add some console.log statements or do some debugging down there and see if it's called on startup?

Also, it might be nothing but I noticed that you said your global cache updates looked like this:

const cacheUpdates = {
  SEND_MESSAGE: sendMessageUpdate,
}

The SEND_MESSAGE key should match exactly the name of the send message mutation as defined in your GraphQL schema. For example if the mutation you have in your schema is something like sendMessage(chatId: ID!, message: String!): Message! then your cache updates object should be like { sendMessage: sendMessageUpdateFn }. Is there maybe a simple mismatch?

bdbch commented 4 years ago

It's not getting executed as far as I can see.

The SEND_MESSAGE key should match exactly the name of the send message mutation as defined in your GraphQL schema. For example if the mutation you have in your schema is something like sendMessage(chatId: ID!, message: String!): Message! then your cache updates object should be like { sendMessage: sendMessageUpdateFn }. Is there maybe a simple mismatch?

Thats a good one! I'll check this asap!

bdbch commented 4 years ago

Unfortunately it's not that one. My mutation gql:

mutation SEND_MESSAGE($chatId: String!, $message: String!, $images: [Upload], $clientGeneratedUUID: String, $forwardMessageIds: [String]) {
  sendMessage(input: { to: $chatId, text: $message }, attachments: $images, clientGeneratedUUID: $clientGeneratedUUID, forwardMessageIds: $forwardMessageIds) {
    ...
  }
}

should be fine right? It seems like the whole update function cache is not started or something as a console.log in the update function won't be executed unless my device goes online again.

darahayes commented 4 years ago

Have you tried the following?

const mutationCacheUpdates = {
  sendMessage: sendMessageUpdateFn
}
bdbch commented 4 years ago

You're saving my day man! Thats it! How is that mapped to each other when both my mutation and function are called "SEND_MESSAGE"? Or is it the actual mutation name provided by the servers typeDefs?

darahayes commented 4 years ago

Looking back at your previous comment:

mutation SEND_MESSAGE($chatId: String!, $message: String!, $images: [Upload], $clientGeneratedUUID: String, $forwardMessageIds: [String]) {
  sendMessage(input: { to: $chatId, text: $message }, attachments: $images, clientGeneratedUUID: $clientGeneratedUUID, forwardMessageIds: $forwardMessageIds) {
    ...
  }
}

the sendMessage name here is the actual name of the mutation as your server understands it. That's the one we use. Now that I think about it deeper, it might make sense to use the SEND_MESSAGE one instead because you might have multiple ways a client might call the same mutation that would have different results and might need different update functions. Right it would be hard to do that with Offix. If we used the outer name (i.e. the SEND_MESSAGE), then we could support that a lot easier. cc @wtrocki this might be a good one for us to investigate.

bdbch commented 4 years ago

Good to hear my issue good for the project too! 👍 Sounds promising and I think would make it easier to understand. I'll look into contributing to the docs when I have time for that! Thank you for your help.

darahayes commented 4 years ago

Rewriting the docs on cache updates and optimistic responses is on my list of things todo but we'd gladly accept any contributions no matter how big or small 😃 If you're happy enough to close your original issue, that would be great.

I'm conscious that we kinda took over this one from @aarshaw so I'll keep this one open in case they come back with more questions or issues.

wtrocki commented 4 years ago

Most of the queries from client are not named to reduce payload size. Named query/mutation can contain multiple underlying graphql queries and mutations. That is the reason we require actual query name in global cache updates. In terms of our underlying engine each operation is processed separately by conflict/offline engine anyway.

I guess we need to document good patterns to not name your queries if single operation is send etc. Also once we integrate offix with graphback we can enforce some standards or even generate cache update functions as they would be easy to build - then our helpers could be deprecated

colin-oos commented 3 years ago

mutationCacheUpdates does not work for me when using GraphQL fragments. If my mutation or updateQuery uses a fragment then it will not restore the state while offline. However, as soon as I remove fragments it works fine. Wondering if anyone else is experiencing this same issue

kingsleyzissou commented 3 years ago

Hi @aarshaw, we have made the decision to move away from the Apollo client and we have decided to deprecate our Offix packages and release our Datastore. I will be closing this issue, but if you have any questions, please feel free to let us know.

You can see the docs for the updated datastore here: https://offix.dev/docs/getting-started