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

Apollo Client integrated local state handling #4338

Merged
merged 30 commits into from
Feb 1, 2019

Conversation

hwillson
Copy link
Member

⚠️ This PR replaces #4155. PR 4155 included a lot of different ideas and POC work, much of which is no longer needed. Now that we're close to finalizing the local state API, this PR includes a clean subset of PR 4155.

In a nutshell, this PR migrates the local state handling capabilities provided by apollo-link-state, into the Apollo Client core. @client directive handling is fully managed by Apollo Client (through the QueryManager), without requiring a separate Apollo Link.

Please note: Apollo Client local state functionality is in an alpha state. We're still considering changes, which might affect the public API.

While opinionated, these changes are being considered to help address several outstanding apollo-link-state issues that are difficult to address in a link chain, and open the door to future Apollo Client changes that integrate local state handling more closely with the Apollo Cache.

The implementation outlined in this PR closely mirrors existing apollo-link-state functionality, with a few changes / additions to make handling local state with Apollo Client more flexible and easy to use.

Key Changes

  • It is no longer necessary to add apollo-link-state to your link chain. Apollo Client includes all local state functionality out of the box.

  • The idea of initializing your local cache with apollo-link-state's fixed defaults option has been replaced with a new initializer function approach. Initializers are simple functions, that can be run during ApolloClient instantiation, or via the ApolloClient.runInitializers function. These functions are mapped against the name of the field you want to update in the cache, and the result of running the function is stored in the cache against that field. Initializers can be as flexible as you need them to be, and can either write the returned result directly to the cache, or be configured to skip writing to the cache, letting you handle that manually inside the initializer function yourself. Each initializer function is called with access to the ApolloClient instance, so the full AC API is available.

  • Initializers do not check to see if they'll overwrite existing data when run. For now we're avoiding the extra cache hit of having initializers check to see if data already exists for a field, before overwriting it. This functionality can be overwritten by manually checking and updating the cache yourself in an initializer function.

  • Initializers are only run once (to help prevent overwriting data accidentally).

  • Local resolvers can be set through the ApolloClient resolvers constructor param, or by calling the ApolloClient.addResolvers function.

  • A local schema can be defined by passing it into the ApolloClient constructor via the typeDefs parameter.

  • Mixing remote and local fields together in queries / selection sets (aka virtual fields) is still supported, along with some additional features. @client ... @export(as: "someVar") can now be used to pass the result of a locally resolved query in as a variable for a remote query, all in the same request. For example:

query authorPosts($authorId: Int!) {                                          
  currentAuthor @client {                                                     
    id @export(as: "authorId")                                                
    firstName                                                                 
  }                                                                           
                                                                              
  author(id: $authorId) {                                                     
    id                                                                        
    firstName                                                                 
    posts {                                                                   
      title                                                                   
      votes                                                                   
    }                                                                         
  } 
}
  • Subscriptions now recognize the @client directive, meaning locally resolved data can be combined with incoming subscription data.

  • Full SSR support.

  • Numerous bug fixes and changes made to address outstanding apollo-link-state issues (issues labelled as ac-local-state-ready are fixed by these changes).

  • And more! 🙂

This PR is still a work in progress (and is not ready for review), but we're pushing hard to get it wrapped up soon.

TODO:

Documentation. This PR is already getting pretty large, so I'll submit the updated local state docs in a separate PR.

@hwillson hwillson requested a review from benjamn as a code owner January 19, 2019 18:48
@JoviDeCroock
Copy link
Contributor

Does this imply that opting out of apollo-link-state requires use of a module build and a bundler who's tree-shaking/DCE is on point?
Just trying to put this out there, with all the bundle size effort being done. Since the esm bundle PR is still open

@hwillson
Copy link
Member Author

@JoviDeCroock If you can use ApolloClient in your project, then you can use the new local state handling capabilities. You don't have to worry about the module format, tree-shaking, dead code elimination, etc. if you don't want to. The bundle size reduction work that's happening in other PR's is super important, and will benefit Apollo Client across the board, but it's not required to use AC's integrated local state handling.

@chaffeqa
Copy link

Correct, and if it matters, I think this is wise. It's really hard to innovate and move quickly when you have dependent code and plugins outside of the main source.

If it helps, I believe that as the tech develops, you will be able to see the proper way to separate and package better.

forceResolvers =
node.arguments
.filter(arg => (
arg.name.value === 'always' &&
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm totally open to changing the @client(always: true) naming, if people don't think it makes sense. I originally had @client(force: true) for example, but thought it wasn't super clear about the fact that adding this variable means we're telling the associated field resolver to always run.

Copy link
Member

Choose a reason for hiding this comment

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

Using always: true crossed my mind too, and I like it! I think it’s a little clearer that you should never pass always: false (because that’s clearly the default) whereas force: false might conceivably trigger some non-default behavior.

@hwillson hwillson self-assigned this Jan 21, 2019
Includes updates to the `apollo-utilities` package to better
accommodate Apollo Client's local state handling capabilities.

- Adds a new transform `buildQueryFromSelectionSet' function to
  help construct a GraphQL Query from a Mutation selection set.
- Adds a new transform `removeClientSetsFromDocument` function
  to prune `@client` selection sets and fields from GraphQL
  documents'.
- Adds a new directive based `hasClientExports` function to see
  if a GraphQL document is using a `@client @export` directive
  combination.
- Adds a new `mergeDeep` utility function for deep cloning.
When using the `@client` directive, it might be desirable in
some cases to want to write a selection set to the store,
without having all of the selection set values available.
This is because the `@client` field values might have already
been written to the cache separately (e.g. via Apollo
Cache's `writeData` capabilities). Because of this, we'll
skip the missing field warning for fields with `@client`
directives.
This is a temporary change, while working on the local state
alpha changes.
This commit provides the bulk of the new Apollo Client local
state handling capabilities. It replaces the need to use
`apollo-link-state` in a link chain for local state management,
by merging local state handling directly into the Apollo Client
core. The majority of the new local state functionality can be
found in the `LocalState` class, which is tied into the
`QueryManager` to integrate with Queries, Mutations and
Subscriptions.

Key Changes:

- Replaces the need to use `apollo-link-state`.
- `apollo-link-state` defaults have been replaced with new
  initializer functions, which are a much more capable and
  flexible way to initialize the cache.
- Initializers and local resolvers can be set through the
  `ApolloClient` constructor, or set separately using new
  public API functions.
- A local schema can be set through `ApolloClient`, which
  can then be used by external tooling (like Apollo Client
  Devtools).
- Mixing remote and local (`@client` based) data together in
  queries is supported.
- Queries can now use the `@export` directive to export the
  result of a local resolver into a variable, that can then
  be automatically passed into a subsequent query. E.g.
  `@client @export(as: "someVar")`
- Full SSR support.
- Numerous bug fixes made to address the oustanding
  `apollo-link-state` issue backlog.
Various tests to cover Apollo Client local state handling.
Remove Boost's dependency on `apollo-link-state`, and wire it up
with AC's local state functionality.
@hwillson hwillson force-pushed the hwillson/local-state branch from 1574a72 to 2520751 Compare January 21, 2019 16:24
By default, local resolvers are controlled by a query's
`fetchPolicy`. If a query that has configured local
resolvers is run with a fresh cache, and that query is
using `ApolloClient.query`'s default `fetchPolicy` of
`cache-and-network`, the local resolvers will be fired
and the result will be stored in the AC cache. If the
same query fires again however, since the query result
already exists in the cache, it will be loaded and used
instead of firing the query's local resolvers again. This
functionality can be altered by using a `fetchPolicy` of
`no-cache` for example, but setting a `fetchPolicy` impacts
an entire query. So if mixing local and remote results and
using a `fetchPolicy` of `no-cache`, local resolvers will
always run, but so will the fetch to retrieve network
based data.

To address this, AC's local state functionality includes
a new `resolverPolicy` approach. Setting a `resolverPolicy`
of `resolver-always` makes sure local resolvers are
always fired for a query, on every request. While this
approach works, it is inflexible due to the fact that
it does not provide a way to only always run certain local
resolvers associated with a query, instead of running all
of them. Apollo Client's `fetchPolicy` approach has also
historically demonstrated that getting `fetchPolicy` settings
right can be a bit tricky (especially for newcomers to the
Apollo ecosystem), so adding another configurable policy
based approach is not overly desirable.

The changes in this commit remove the `resolverPolicy`
functionality. They then ensure that fields marked with
`@client(always: true)` always have their local resolvers
run, on each request. This provides a way to control
exactly which parts of a query should have its local
resolvers always run, and which parts can continue to
leverage the cache.

Technical side note: when using `@client(always: true)`,
the full query will be resolved from the cache first,
before any local resolvers are run. So if data already
exists in the cache for a field that's marked with
`@client(always: true)`, it's loaded first as part of
reading the fully executed query from the cache (including
local and remote results). That data (if it exists) is
then overwritten by the result from the associated local
resolver, and returned as part of the query result. This
load then override approach makes sure that the integrity
of the cache is not affected by running local resolvers.
@hwillson hwillson force-pushed the hwillson/local-state branch from 2520751 to ff89ccd Compare January 21, 2019 16:30
@benjamn benjamn added this to the Release 2.5.0 milestone Jan 22, 2019
@benjamn benjamn mentioned this pull request Jan 23, 2019
@benjamn benjamn changed the base branch from master to release-2.5.0 January 23, 2019 16:19
benjamn and others added 2 commits January 24, 2019 10:01
Running

  tsc -p tsconfig.test.json --noEmit

in the apollo-client/packages/apollo-client directory produced a number of
warnings and errors before this commit (tsc version 3.2.2).
@mbrowne
Copy link

mbrowne commented Jan 24, 2019

Thanks for all the great work on this! This feedback might be premature, but in case it influences the API design in any way, I think an important use case will be building additional abstractions on top of local state. For example, here's an idea I have for setting up mutations without as much boilerplate code:

export const actions = createUpdaters(client, {
    counter: {
        increment: ({ count }) => ({
            count: ++count
        }),
        decrement: ({ count }) => ({
            count: --count
        })
    },
    currentUser: {
        changeName: (state, newName: string) => ({
            name: newName
        })
    }
})

The initial state that goes along with this example looks like this:

{
    // counter doesn't need to be an object of course, since it only has one property;
    // just making it an object for the sake of the example
    counter: {
        count: 0
    },
    currentUser: {
        name: 'Guest'
    }
}

createUpdaters() would turn these into updater functions that call a mutation behind the scenes. (A nice thing about mutations rather than writing to the cache directly is that they provide a possible opportunity to log every local state change, meaning something like Redux's time traveling debugging tool would be possible.)

I put together a quick POC here for anyone who's interested:
https://github.com/mbrowne/apollo-state-actions/tree/master/demo/src

One issue I ran into is that there seems to be no way to get a full object from the cache without knowing specifically which fields to request in advance. So for now I hacked it to access the private cache.data property directly. I'm not asking for a solution here, just wanted to mention it, since a non-hacky way to do this would be handy for creating libraries like this.

By relying on normal promise rejection, we can avoid needing the onError
callback parameter.

Removing await expressions in favor of explicit promises tends to save a
small amount of bundle size. It's still a good idea to keep runResolvers
async, because async functions reliably capture any exceptions thrown
during the execution of the function.
Besides removing await expressions, this commit performs a single
mergeDeepArray instead of multiple binary mergeDeep calls.
This type was already quite different from the GraphQLResolveInfo type
used by the GraphQL specification, so I think we should limit it to just
the necessary information while we still have that freedom:
https://graphql.org/learn/execution/#root-fields-resolvers

If a resolver needs them, the { isLeaf, resultKey, directives } data
previously provided can all be derived from the FieldNode.
@benjamn benjamn force-pushed the hwillson/local-state branch from 555b423 to 8af8875 Compare January 24, 2019 17:54
benjamn and others added 4 commits January 24, 2019 14:37
On the surface, the idea of using initializers as a way to
dynamically prep the cache seemed like a good idea. It was a
more flexible way to initiliaze the cache than the `defaults`
approach `apollo-link-state` uses. In practice however,
everything `initializers` can do `cache.writeData` can do as
well. Yes initializers are a little more user friendly to work
with, but `cache.writeData` is already part of the cache API and
is much more flexible in terms of being able to be called anywhere
the cache is available, and it's explicit in what it's doing.
Given this, we've decided to remove initializer support from
Apollo Client, keeping the local state changes focused on the
idea of running local resolvers.

While prepping the cache is still an important peice of
functionality, we'll update the local state docs to show how
`cache.writeData` can be used to handle everything
`initializers` were being used for, including re-initializing
the cache after a store reset.

Removing initializers also has the added benefit of reducing
the Apollo Client bundle size by a good chunk.
The LocalState#runResolvers method now requires a remoteResult option of
type ExecutionResult<TData> and returns a Promise<ExecutionResult<TData>>,
and this contract is enforced (somewhat better) using generic types.
Since the new local state changes will be rolled out in a minor
release, we have to make sure Boost local state support is
backwards compatible. To do this, this commit adjusts the
`clientState` parameter approach such that if `defaults` are
passed in, they are automatically written to the cache using
`cache.writeData`. Also, the Boost constructor has been updated
to line up with the new `ApolloClient` constructor changes,
meaning `resolvers`, `typeDefs`, and `fragmentMatcher` can now
be passed into the Boost constructor.

Other than making sure Boost doesn't break when the local state
changes are rolled out, we won't be making other Boost changes.
Apollo Boost is still slotted for deprecation in AC 3.0.
[field: string]: (
rootValue?: any,
args?: any,
context?: any,
Copy link

@pashazz pashazz Jan 31, 2019

Choose a reason for hiding this comment

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

Shouldn't there be a some kind of context interface that provides cache, getCacheKey as per documentation?

We're going to avoid getters/setters for `typeDefs` for now,
and instead recommend `typeDefs` are set via the `ApolloClient`
constructor. We were originally exposing getters/setters for
integrations like Apollo Client Devtools, but we'll adjust
devtools to access the `typeDefs` in a different way. We will
likely want to revisit this decision in the future, to
accommodate the possibility that people want to add to their
local schema at different points in their application, but
we'll cross that bridge when we get there. For now keeping the
changes to the public `ApolloClient` API minimal, is the goal.
@hwillson hwillson merged commit 3a5be65 into release-2.5.0 Feb 1, 2019
@hwillson hwillson deleted the hwillson/local-state branch February 1, 2019 17:04
@fpaboim
Copy link

fpaboim commented Feb 20, 2019

I feel that local queries are rarely computationally expensive and that most of the reason to use caching is because of the network latency/bandwidth issues, as such wouldn't it make sense to default to @client (always: false)? Or to be able to at least set it as default behavior if I so choose would be great. I feel caching local data is a great potential source for bug complaints in the future as users from other local state management libraries won't be expecting their local state to be cached and potentially read from a stale state. TBH, I find it hard to find a use case where you're running a long winding local query where caching would be a good solution, probably would be better to use workers and update your local state or something.

@mbrowne
Copy link

mbrowne commented Feb 20, 2019

@fpaboim What about when you call cache.writeData() (or writeQuery or writeFragment)? Those methods write directly to the cache of course, and the next time you query the same fields I would expect it to return what you explicitly wrote to the cache rather than calling the resolver again.

@fpaboim
Copy link

fpaboim commented Feb 22, 2019

True, and those should be most use cases I guess... on the other side besides localStorage the only other thing I could think of is accessing some other state, like someone using redux/mobx/context for local state?

@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.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants