urql-graphql / urql

The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
https://urql.dev/goto/docs
MIT License
8.6k stars 447 forks source link

Optimistic responses and data invalidation not working as expected #1653

Closed strblr closed 3 years ago

strblr commented 3 years ago

urql version & exchanges: 2.0.1, graphcache 4.0.0

I'm having two issues with optimistic responses and data invalidation. I don't know if they are related. To set up the stage, imagine having the following very simplified schema :

type Project {
  id: ID!
  title: String!
  contradiction: Contradiction!
}

type Contradiction {
  id: ID!
  label: String!
  project: Project!
}

Query {
  project(id: ID!): Project!
}

Mutation {
  editContradiction(id: ID!, label: String!): Contradiction!
}

A contradiction is part of a project, and can be edited via Mutation.editContradiction. A project can be retrieved via Query.project. Let's add the following (also very simplified) Urql client :

const client = createClient({
  url: "...",
  requestPolicy: "cache-and-network",
  exchanges: [
    dedupExchange,
    cacheExchange({
      updates: {
        Mutation: {
          editContradiction({ editContradiction }, _, cache, { optimistic }) {
            if (optimistic) return;
            cache.invalidate({
              __typename: "Project",
              id: editContradiction .project.id
            });
          },
        }
      },
      optimistic: {
        editContradiction (vars, cache) {
          const contradiction = cache.readFragment(
            ContradictionFragmentDoc,
            { __typename: "Contradiction", id: vars.id }
          );
          if (!contradiction) return null;
          return {
            ...contradiction,
            label: vars.label
          };
        }
      }
    }),
    fetchExchange
  ]
})

Here are my issues :

1) When I trigger editContradiction two times in a row, with the second one being triggered before the first had a chance to return from the server, the optimistic update is not applied for that second one. The UI is just frozen after that second trigger until the mutation actually completes.

2) When invalidating the contradiction's Project in updates as shown in the code snippet above, the second trigger of editContradiction causes my project data (from a useQuery on Query.project) to be set to null for a brief period of time until it's refetched. This seems to happen after the first call of editContradiction returned from the server although I'm not 100% sure. If I wait for each editContradiction to finish and for Query.project to be called again as a result of the invalidation before calling another editContradiction, Query.project is never set to null. I absolutely don't know what's going on here.

My workaround for the first issue is to store the contradiction state in a React state that can be updated immediately, and call editContradiction without an optimistic response as a side effect of those React state updates.

But I don't have a workaround for the second issue, which currently breaks the UI (because Query.user being null not only violates the typing generated by codegen (which only allows for the actual data or undefined), it also triggers the rendering of a big loader that unmounts the whole project UI). This is a problem for all users clicking fast on mutation buttons while having a slow internet.

Thanks in advance for your help.

kitten commented 3 years ago

When you invalidate you delete data from the cache; hence an optimistic mutation can never invalidate data eagerly and expect a cached response for this data.

Furthermore when the UI doesn't reflect the changes you've made then that means it's being forced to wait for the mutation to complete and it being able to send a request to the API because some of your data is missing.

Most recent discussion with info on this is this one: https://github.com/FormidableLabs/urql/discussions/1645

Edit: Also I spotted another small thing. Invalidating is an explicit call to delete and invalidate data, like is said, and I spotted that you expect project to be temporarily set to null. Two conditions are however preventing you from seeing this:

strblr commented 3 years ago

@kitten

I'm not sure I understand all the implication of your explanation.

When you invalidate you delete data from the cache; hence an optimistic mutation can never invalidate data eagerly and expect a cached response for this data.

Actually, the data of my useQuery (on the project query) is not nullified in case of a single invalidation. And that's fortunate because otherwise how could one refetch outdated queries to repopulate the cache after a mutation without having loaders and spinners popping up all over the app ? Right now, if I call cache.invalidate on my Project one time after a mutation, useQuery is refetched but still shows the old Project (please don't change that haha, Apollo did in this unpopular PR and it was one of the many reasons I switched).

My Query.project data is only nullified if the mutation is called two times too fast. I don't know if it's because the second mutation needs the first one to finish, or if the second mutation needs the query refetches triggered by the first one (after cache invalidation in updates) to finish. Is it possible that the Query.project data is forced to null after two consecutive invalidations on the same entity ?

Furthermore when the UI doesn't reflect the changes you've made then that means it's being forced to wait for the mutation to complete and it being able to send a request to the API because some of your data is missing.

I'm trying to imagine what's going on here :

1) First mutation is called, thus optimistic.editContradiction is called, the contradiction fragment is complete and the UI updates immediately. 2) The first mutation returns from the server, updates.Mutation.editContradiction is called and invalidates a specific Project. 3) This invalidation triggers the refetch of a Query.project query. 4) While this refetch happens, the second mutation is called, followed by optimistic.editContradiction. 5) While reading the contradiction fragment, the Contradiction.project field is now missing (invalidated in 2.). 6) So the optimistic result is not applied and waits for the actual mutation to respond. Therefor, I lost the benefit of using optimistic results.

Does it seem like that's what's going on ? If so, what would be the solution ? Is there a way to repopulate the cache with new query data after a mutation without deleting cached data (simply overwriting it) ?

and I spotted that you expect project to be temporarily set to null

No, I actually expect project to never be null, as specified in the schema.

This means that it isn't actually nullable, so even with schema awareness it wouldn't be set to null

But why is it, then ?