-
Notifications
You must be signed in to change notification settings - Fork 2.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Remove experimental cache.modify method. #6289
Conversation
Those test failures look legitimate, but I'll investigate next week. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These changes are 👍 @benjamn - thanks!
da198a9
to
2d1c120
Compare
ef6df37
to
4699f5c
Compare
The `cache.modify` API was first introduced in #5909 as a quick way to transform the values of specific existing fields in the cache. At the time, `cache.modify` seemed promising as an alternative to the `readQuery`-transform-`writeQuery` pattern, but feedback has been mixed, most importantly from our developer experience team, who helped me understand why `cache.modify` would be difficult to learn and to teach. While my refactoring in #6221 addressed concerns about broadcasting `cache.modify` updates automatically, the bigger problem with `cache.modify` is simply that it requires knowledge of the internal workings of the cache, making it nearly impossible to explain to developers who are not already Apollo Client 3 experts. As much as I wanted `cache.modify` to be a selling point for Apollo Client 3, it simply wasn't safe to use without a firm understanding of concepts like cache normalization, references, field identity, read/merge functions and their options API, and the `options.toReference(object, true)` helper function (#5970). By contrast, the `readQuery`-transform-`writeQuery` pattern may be a bit more verbose, but it has none of these problems, because these older methods work in terms of GraphQL result objects, rather than exposing the internal data format of the `EntityStore`. Since `cache.modify` was motivated to some extent by vague power-user performance concerns, it's worth noting that we recently made `writeQuery` and `writeFragment` even more efficient when rewriting unchanged results (#6274), so whatever performance gap there might have been between `cache.modify` and `readQuery`/`writeQuery` should now be even less noticeable. One final reason that `cache.modify` seemed like a good idea was that custom `merge` functions can interfere with certain `writeQuery` patterns, such as deleting an item from a paginated list. If you run into trouble with `merge` functions running when you don't want them to, we recommend calling `cache.evict` before `cache.writeQuery` to ensure your `merge` function won't be confused by existing field data when you write your modified data back into the cache.
fd2c8fa
to
09b5d7c
Compare
@benjamn let me say that I strongly disagree with that (and it's probably an understatement). Removing this functionality is a HUGE stepback IMO and was one of the main reasons why I was interested in Apollo 3 in the first place.
If you don't know how and why Apollo does normalization you shouldn't use it in the first place, because you're basically a time bomb ready to do damage.
What's so difficult about references? Some of the best trending libraries use them (see https://mikro-orm.io/docs/entity-references/) and their users are super-happy about it: there is even discussion in the state tracker about how to further improve it and it's already happening in the dev branch.
That's the whole point of cache.modify. Nobody forces you to use it and if some corporation is against it we could have simply created a linting rule to forbid it. Why should everyone else be forced to not use it? Also, let me say that I have a whole application written with Apollo 3 almost ready to go to production thanks to this feature alone. I know this is not a stable branch, but the benefits far exceeded the few bleeding edges and it was never stated before that cache.modify was an experimental feature: an RC was even expected in May! How am I supposed to update paginated queries with dozens of filters (including search ones) with readQuery and readFragment whenever I delete an item? Should I perform a writeQuery on each and every possible combination of args, including search terms!? Should I use cache.evict and just refetch all of them for no reason at all? Really, I don't understand: Apollo 3 was shaping up so well :( I see a "discussion" label on this, but you can't really expect to drop a bomb like this just before the weekend and expect everyone else to notice in time. |
Bad news. We also really waited for something to improve in this area. We never use So the only tool at our disposal is neandertal Having said that, I appreciate the amount of work done by @benjamn and I trust his intuition. |
I have to agree. Removing it close to the release date (according to Apollo's Twitter), after more than 3 months is a big setback. As a company, we now have to dedicate time to refactor code back to what it already was. As mentioned in a similar comment above, it was the main reason we switched from Apollo v2 to Apollo v3. I don't understand why supporting it creates a problem, since the previous I understand that beta testers will face breaking changes, but a removal of such a big feature was not expected from my side; especially so late down the "beta". |
Thanks for the really great feedback all. We're discussing this further internally and will post back shortly. |
First of all, thanks to everyone for using (really using!) the betas, for your patience while we address feedback about bugs and usability from lots of different developers with different needs and appetites for different kinds of complexity, and for paying enough attention to have strong feelings about this removal. We deeply appreciate your help. I am open to reconsidering how the As a more detailed example of the kind of mistakes that I am sure people would make using cache.modify({
todos(existing) {
return [...existing, newTodo];
},
}, cache.identify({
__typename: "TodoList",
id: "...",
}) Unfortunately, this code is broken (assuming the If you realized that you need to use cache.modify({
todos(existing, { toReference }) {
return [...existing, toReference(newTodo)];
},
}, cache.identify({
__typename: "TodoList",
id: "...",
}) But this code is still broken! The The full solution is to let You could get it to work, but Even if you were nodding along to everything I said above (feel free to let me know if that's the case, so I know there's at least a few of you out there, hi 👋 ), I sincerely do not believe you can expect all of your teammates to internalize these details before interacting with I'll post a follow-up comment with some ideas about how |
@benjamn There is a few of us out here that nodded along ;) |
Those are all valid points and yes, we were aware of it. I may be speaking for myself, but I'm pretty sure that most of us beta-testers are going through the various PRs and I have to say that you always do a wonderful job writing extensive descriptions detailing the internals. |
I haven't tried the beta yet, but I've been more excited about the |
First of all, I want to thank everyone who has tried the new beta. It's great to see your feedback here. I also really want to thank @benjamn for all of his hard work on this. I fully agree with your comments. As a newcomer to Apollo Client, I went the Out of empathy for the new Apollo Client user, I think the decision to remove To better understand where people fall off, I think the learning path for newcomers to Apollo Client looks something like this:
Regardless of how users accomplish (3), I think these three learning milestones empower beginners to accomplish about 95% of the most common use cases, even with minimal (or no) understanding of how the cache works under the hood.
For example, when deleting a const [mutate, { data, error }] = useMutation<
DeleteTodoTypes.DeleteTodo,
DeleteTodoTypes.DeleteTodoVariables
>(
DELETE_TODO,
{
update (cache, { data }) {
const deletedTodoId = data?.deleteTodo.todo?.id;
const allTodos = cache.readQuery<GetAllTodos>({
query: GET_ALL_TODOS
});
cache.writeQuery({
query: GET_ALL_TODOS,
data: {
todos: {
edges: allTodos?.todos.edges.filter((t) => t?.node.id !== deletedTodoId)
},
},
});
}
}
)
mutate({ variables: { id: 2 }}) This is what you might expect someone just starting with AC to do. This is also not entirely correct. In order to fully delete that todo, we'd need to use The same example, but this time using the advanced cache manipulation APIs could look like this: const [mutate, { data, error }] = useMutation<
DeleteTodoTypes.DeleteTodo,
DeleteTodoTypes.DeleteTodoVariables
>(
DELETE_TODO,
{
update (cache, el) {
const deletedId = el.data?.deleteTodo.todo?.id
cache.modify({
todos (existingTodos, { readField }) { // a
const newTodos = { // b
...existingTodos,
edges: existingTodos.edges.filter((edge: any) => {
return deletedId !== readField('id', edge.node); // c
})
}
return newTodos;
}
});
cache.evict(`Todo:${deletedId}`) // d
}
}
)
mutate({ variables: { id: 2 }}) Here's the prerequisite knowledge you need to have in order to understand this:
I don't think it's fair to assume most people know this without adequate education. So then to play with the big kid tools, my opinion is that users would then need:
There's a bunch of things we could do here:
cc @darkbasic |
Hello 👋 First of all, thanks you, Apollo team for hard work on building great product and being there for devs on GitHub, Twitch sessions etc. Sometimes, product going through some hard decisions. And it’s understandable. Unfortunately, I’m one of the “along nodders”, that @angelnar87 mentioned :) My team and me are using beta in our new feature for the upcoming release, and using the |
I am kinda sad seeing
async function removeEntry (variables: RemoveEntryVariables, client: ApolloClient<NormalizedCacheObject>): Promise<void> {
await client.mutate<RemoveEntry, RemoveEntryVariables>({
mutation: removeEntryMutation,
variables,
optimisticResponse: {
removeEntry: variables.id
},
update (cache) {
cache.modify({
entries(value: Reference[], {readField}) {
return value.filter(entry => readField('id', entry) !== variables.id)
},
});
cache.gc();
}
})
} (full example project can be found here) To achive the same thing using |
Yeah, this one was a big surprise for me when I updated to beta 50. |
As a compromise, in my case I would already be satisfied with a method that returns all cached variables for a query, like for example cache.getQueryVariables<T = any>(queryName: string): T[] That way, the update function could look like this update (cache) {
cache.getQueryVariables<EntriesVariables>('entries').forEach(variables => {
const read = cache.readQuery<Entries, EntriesVariables>({
query,
variables
});
const data = modify(read);
cache.writeQuery<Entries, EntriesVariables>({
query,
variables,
data
)};
});
} |
@WIStudent that would work, I also just proposed an |
Couldn't have said it better. At first I was going to say there should be a central place for us to declare what to update after a given mutation, but then I realized, it's already possible in userland to put an In any case you might be interested in my [ |
Greetings all! I'm just coming back from a nice four-day weekend where I didn't look at my work computer at all, hence the delay. Thanks to your feedback (especially @dmitrybirin's #6289 (comment) above, and @darkbasic's point about not being able to test other new AC3 features until In addition to reverting this PR, here's a list of changes I think we need to make to smooth out the
Putting everything together, here's an improved/expanded version of the example I used above: const newTodo = {
__typename: "Todo",
id: ...,
description: "Reinstate cache.modify with an improved API",
completed: false,
};
cache.modify({
modifiers: {
todos(existing: Reference[], { cache, readField }) {
// Per my proposal above, it should be possible to call cache.writeFragment
// without knowing the ID, as long as cache.identify(newTodo) works.
const newTodoRef = cache.writeFragment({
data: newTodo,
// This fragment only needs to describe the contents of newTodo,
// not any of the other objects in this TodoList.todos field.
fragment: gql`fragment NewTodo on Todo {
id
description
completed
}`,
});
// The data have been written, so we can skip appending newTodoRef
// to the existing array if it already exists.
if (existing.some(ref => readField("id", ref) === newTodo.id)) {
return existing;
}
return [...existing, newTodoRef];
},
},
// Optional when targeting the ROOT_QUERY object.
id: cache.identify({
__typename: "TodoList",
id: ...,
}),
// True by default, but worth reiterating: this call to cache.modify
// should trigger only one broadcast, despite using writeFragment
// within the `todos` modifier function.
broadcast: true,
}); I'm sure some additional nuances will become apparent as we get this implemented, but that's the general plan so far. We are aiming to have a release candidate by the end of this week (May 29th), including this work and a handful of other new features. Once the RC period starts, we will not add (or remove!) any new features before the AC3 launch, but we will continue fixing bugs and improving the documentation. |
@benjamn, this is great news. Thanks for being so receptive and responsive on this stuff! |
The more I think through how I would use const newTodo = {
__typename: "Todo",
id: ...,
description: "Reinstate cache.modify with an improved API",
completed: false,
}
cache.updateFragment({
fragment: gql`
fragment todos on TodoList {
id
todos {
id
description
completed
}
}
`,
id: ...,
updater: ({ data }) => ({
...data,
todos: [...data.todos, newTodo],
})
}) Though I guess it's possible to implement a method like this with userland code (I wonder if I will ever end up needing to use However I think the most crucial missing feature is still bulk updates for all variables a query has been made with as discussed in my |
Actually I forget that |
@jedwards1211 No need to remind me that However, I am still convinced that I was tempted to bring back a version of By contrast, |
Those are good points...mainly I just hope to see the cache store the variables and args unserialized, and pass them to some read or modify methods, so that we can perform updates that are currently impossible. Do you think that's in the near future? |
@jedwards1211 I would like to see For Implementing Historically, storing the original arguments alongside field values has not been necessary for reading and writing queries or fragments, because the given query/fragment plus variables allows computing specific, unambiguous arguments for any field. What you've sketched so far with Both In the meantime, please feel free to share any compelling use cases that |
What if multiple queries with different arguments for a field are active or cached though? Would the updater be called for each one? |
@benjamn I appreciate your work very much. But right now it's very misleading for those who upgrade. Before upgrading I've done my job and checked the docs and googled some articles, |
@benjamn thanks, your proposed modifications sound good. Just a small question regarding your example: todos(existing: Reference[], { cache, readField }) {
const newTodoRef = cache.writeFragment({...});
// The data have been written, so we can skip appending newTodoRef
// to the existing array if it already exists.
if (existing.some(ref => readField("id", ref) === newTodo.id)) {
return existing;
} Why wouldn't you need to add
If you're really commited to release an RC before the end of May, so be it. Otherwise I think we could use some more time to stabilize before v3 gets exposed to a broader public, especially things like this: #6183 (comment) Regarding |
The |
@benjamn thanks for looking into another stab at the With the old
Which worked great! We appended our added todo to the list. But with the This also makes it impossible if I want to do something generic like a rule that any time a Todo is created, add it to the todos list. There may be multiple mutations for |
@danReynolds That usage of |
Ah I mistook deemphasize for discourage, sounds good! |
We will definitely need more documentation about cache.modify after reverting PR #6289, but in the meantime the existing documentation should not be blatantly incorrect.
Alright everybody, please head over to #6350 if you want to continue this discussion! |
New |
The
cache.modify
API was first introduced in #5909 as a quick way to transform the values of specific existing fields in the cache. At the time,cache.modify
seemed promising as an alternative to thereadQuery
-transform-writeQuery
pattern, but feedback has been mixed, most importantly from our developer experience team, who have helped me understand whycache.modify
would be difficult to learn and to teach.While my refactoring in #6221 addressed concerns about broadcasting
cache.modify
updates automatically, the bigger problem withcache.modify
is simply that it requires knowledge of the internal workings of the cache, making it nearly impossible to explain to developers who are not already Apollo Client 3 experts. As much as I wantedcache.modify
to be a selling point for Apollo Client 3, it simply wasn't safe to use without a firm understanding of concepts like cache normalization, references, field identity (keyArgs
), read/merge functions and their options API, and theoptions.toReference(object, true)
helper function (#5970).By contrast, the
readQuery
-transform-writeQuery
pattern may be a bit more verbose, but it has none of these problems, because these older methods work in terms of GraphQL result objects, rather than exposing the internal data format of theEntityStore
.Since
cache.modify
was motivated to some extent by vague power-user performance concerns, it's worth noting that we recently madewriteQuery
andwriteFragment
even more efficient when rewriting unchanged results (#6274), so whatever performance gap there might have been betweencache.modify
andreadQuery
/writeQuery
should now be even less noticeable.One final reason that
cache.modify
seemed like a good idea was that custommerge
functions can interfere with certainwriteQuery
patterns, such as deleting an item from a paginated list. If you run into trouble withmerge
functions running when you don't want them to, we recommend callingcache.evict
beforecache.writeQuery
to ensure yourmerge
function won't be confused by existing field data when you write your modified data back into the cache.