Skip to content
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

Avoid costly cloneDeep snapshots when cache results are known to be immutable. #4543

Merged
merged 4 commits into from
Mar 7, 2019

Conversation

benjamn
Copy link
Member

@benjamn benjamn commented Mar 6, 2019

The cloneDeep helper from apollo-utilities has begun showing up prominently in some performance profiles (#4464), because we were forced to start taking snapshots of previous results to guard against destructive modifications (#4069).

While we cannot mandate immutable data handling for all application code that interacts with Apollo Client, we can give developers the tools to eliminate the runtime cost of using cloneDeep, which is incurred to defend against destructive modifications of cache results. If we don't have to worry about destructive updates, we can skip calling cloneDeep. Some cooperation from the application developer is required to achieve this goal, and that's what this PR enables.

This new (highly recommended) strategy has two parts:

const client = new ApolloClient({
  link: ...,
  cache: new InMemoryCache({
    freezeResults: true, // new
  }),
  assumeImmutableResults: true, // new
});
  1. Thanks to Support InMemoryCache({ freezeResults: true }) to help enforce immutability. #4514, when you create an instance of InMemoryCache, you can instruct it to call Object.freeze (in development only) against all the result objects that it creates, which effectively prevents any destructive mutations of those objects. If you have been relying on mutability of cache results, you may need to update some of your code, but the freezeResults option should help you find the problematic spots, because modifications of frozen objects throw an exception (in strict mode). Since the freezing happens only in development, this new functionality has zero runtime cost in production.

  2. Once you know that your application code is no longer modifying cache results destructively, you can unlock significant performance improvements by passing assumeImmutableResults: true to the ApolloClient constructor. Once you have agreed not to modify cache results, the client can avoid using cloneDeep to take snapshots of previous results, and identical result (sub)trees will often be === to each other, so your UI rendering code can immediately detect when no changes have happened.

What do we mean by destructive modifications? For example, the following code destructively modifies a result object read from the cache:

const data = client.readQuery({ query });

const myNewTodo = {
  id: '6',
  text: 'Start using Apollo Client.',
  completed: false,
};

data.todos.push(myNewTodo);

client.writeQuery({ query, data });

While this works, Apollo Client has to do a lot of extra work to compensate for the possibility that nested result data might change at any time.

To rewrite this code in a non-destructive style, you should create a new data.todos list that includes the old todos plus myNewTodo:

const data = client.readQuery({ query });

const myNewTodo = {
  id: '6',
  text: 'Start using Apollo Client.',
  completed: false,
};

client.writeQuery({
  query,
  data: {
    todos: [...data.todos, myNewTodo],
  },
});

Both styles will work, but only the second style is compatible with the { assumeImmutableResults: true } option. When you're using new InMemoryCache({ freezeResults }), the data.todos.push(myNewTodo) expression will throw an exception in development (in strict mode), because the data.todos object will be frozen.

Creating the QueryManager lazily has limited value, since almost nothing
ApolloClient needs to do can happen without the QueryManager.

This change cuts 183 bytes from the minified size of the package (60 bytes
after gzip).
Part of the plan I outlined in this comment:
#4464 (comment)

Passing { assumeImmutableResults: true } to the ApolloClient constructor
should probably always be accompanied by passing { freezeResults: true }
to the InMemoryCache constructor (see #4514), though of course the use of
InMemoryCache is optional, and other cache implementations may not support
that option.
@benjamn benjamn self-assigned this Mar 6, 2019
Copy link
Member

@hwillson hwillson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks awesome @benjamn!

@@ -72,19 +73,16 @@ export default class ApolloClient<TCacheShape> implements DataProxy {
public link: ApolloLink;
public store: DataStore<TCacheShape>;
public cache: ApolloCache<TCacheShape>;
public queryManager: QueryManager<TCacheShape> | undefined;
public readonly queryManager: QueryManager<TCacheShape>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm really happy to see this, but do you think we need to worry about this potentially impacting backcompat? People really shouldn't be setting their own queryManager, but we've unfortunately allowed it for a while.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@danilobuerger Any thoughts on how risky adding readonly might be?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What @hwillson said, they shouldn't be setting it, but they might have. But since its just a typing change, its still settable if someone really wanted to. So I don't see an issue with that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After sleeping on it (and doing a code search for initQueryManager and queryManager), here's my assessment of the use cases:

  • By far the most common use case for calling client.initQueryManager() is simply to ensure client.queryManager exists so it can be examined before the first query happens (e.g. to save a reference to client.queryManager.queryStore). These changes render that effort unnecessary.
  • Mistakenly calling initQueryManager in an attempt to reset client state. This still works as well as it ever did, because initQueryManager was previously idempotent, and now does nothing.
  • Hypothetical use cases that I haven't actually found in the wild:
    • Setting client.queryManager = null and later calling client.initQueryManager() to recreate it. This no longer works because initQueryManager won't recreate the QueryManager now, so adding readonly to client.queryManager seems like a helpful signal to reconsider this pattern.
    • Saving client.queryManager somewhere, setting it to null temporarily, and then restoring it to the old value later. This still works, as long as you circumvent the readonly restriction with client as any, which seems like a reasonable "I know what I'm doing" requirement.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good assessment, the only other thing I could come up with is mocking it away for some reason in tests. (However, I don't know why one would want to do that)

packages/apollo-client/src/ApolloClient.ts Show resolved Hide resolved
@benjamn benjamn merged commit 9ce1011 into master Mar 7, 2019
@benjamn benjamn deleted the assumeImmutableResults branch March 7, 2019 13:54
@danilobuerger
Copy link
Contributor

This PR sounds fantastic @benjamn ! Could we get a snapshot release of this to get more eyes on testing the performance impact?

benjamn added a commit that referenced this pull request Mar 7, 2019
benjamn pushed a commit that referenced this pull request May 20, 2019
The `readQuery` call may return an object that is shared with the current contents of cache, which can cause some weird behavior if you mutate the `readQuery` result.

See also:
#4514
#4543
AlexeyMatskevich pushed a commit to AlexeyMatskevich/ohmuritel that referenced this pull request Nov 26, 2019
apollographql/apollo-client#4543

* Enable strict mode in all scripts
benjamn added a commit that referenced this pull request Feb 16, 2023
This commit allows the default value of the `assumeImmutableResults`
option to be determined by the cache (any implementation of
`ApolloCache`), which allows our implementation of `InMemoryCache` to
express the safety of assuming `assumeImmutableResults` is `true`,
unlocking significant performance savings (fewer defensive deep copies
of query results), even if `assumeImmutableResults` is not configured.

Related past PRs:
- #4543
- #5153
- #9680
- [Apollo Client 2.6 blog post](https://www.apollographql.com/blog/announcement/frontend/whats-new-in-apollo-client-2-6/#rewarding-immutability)
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 17, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants