-
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
Allow registering named fragments with InMemoryCache
to support using ...FragmentName
in queries without redeclaring FragmentName
in every query
#9764
Conversation
InMemoryCache
to support ...FragmentName
in queries without redeclaring FragmentName
in the queryInMemoryCache
to support using ...FragmentName
in queries without redeclaring FragmentName
in every query
// Performing transformForLink here gives this.cache a chance to fill in | ||
// missing fragment definitions (for example) before sending this document | ||
// through the link chain. | ||
const linkDocument = this.cache.transformForLink( | ||
// Use same document originally produced by this.cache.transformDocument. | ||
this.transform(queryInfo.document!).document | ||
); | ||
|
||
return asyncMap( | ||
this.getObservableFromLink( | ||
queryInfo.document!, | ||
linkDocument, |
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.
Surfacing an important commit message (from the first commit in this PR):
Call cache.transformForLink
before each link execution
This is not a disruptive change for
InMemoryCache
, since it uses the default implementation oftransformForLink
(inherited fromApolloCache
), which simply returns the given document.If you're using a different
ApolloCache
implementation that has a customtransformForLink
implementation, this new behavior should be more convenient and flexible, but you should probably double-check that yourtransformForLink
method is suitably cached/idempotent, so multiple calls with the same input document return the same (===
) transformed document.
This is cool, and I'm curious if we need to update some documentation on the ability to |
@jpvajda You are right about the need for more documentation. Happy to work on that in this PR, since it would help explain the PR as well. |
definitions.forEach((node, name) => { | ||
if (node !== this.registry[name]) { | ||
this.registry[name] = node; | ||
this.invalidate(name); |
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.
Thanks to this invalidation, if you register new fragments under previously registered names, any queries that used the previous fragment(s) will be invalidated and rerun. In other words, the FragmentRegistry
is fully reactive.
@benjamn that's great I can definitely act as content reviewer and perhaps @StephenBarlow or Rose could do a pass through what we come up as needed. |
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.
This is really awesome @benjamn! 🎉
}); | ||
}); | ||
|
||
it("can register fragments with unbound ...spreads", () => { |
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.
The name of this test threw me off a bit on the intent. Maybe can override pre-registered fragments
instead?
@@ -514,6 +517,13 @@ export class InMemoryCache extends ApolloCache<NormalizedCacheObject> { | |||
return document; | |||
} | |||
|
|||
public transformForLink(document: DocumentNode): DocumentNode { |
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.
Definitely not an experimental API anymore!
`), | ||
}); | ||
|
||
// TODO Allow writeFragment to just use fragmentName:"BasicFragment"? |
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.
YES! 🎉 That would be super cool.
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.
(doesn't have to be in this PR of course)
if (def) { | ||
defsToAppend.push(def); | ||
} else { | ||
// TODO Warn? Error? |
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.
If we end up here does that mean we have fragment spreads that don't have a matching fragment definition defined globally or locally? If so, I'm leaning towards an error (with a nice explanatory error message mentioning the fragment name); might as well fail fast if that's the case.
f14369e
to
1d91325
Compare
1d91325
to
3ffb5eb
Compare
if (e instanceof MissingFieldError) { | ||
// Swallow MissingFieldError and return null, so callers do not | ||
// need to worry about catching "normal" exceptions resulting from | ||
// incomplete cache data. Unexpected errors will be re-thrown. If | ||
// you need more information about which fields were missing, use | ||
// cache.diff instead, and examine diffResult.missing. | ||
// Swallow MissingFieldError and return null, so callers do not need to | ||
// worry about catching "normal" exceptions resulting from incomplete | ||
// cache data. Unexpected errors will be re-thrown. If you need more | ||
// information about which fields were missing, use cache.diff instead, | ||
// and examine diffResult.missing. | ||
return null; |
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.
I thought about carving out an exception for missing named fragments, like the existing exception for MissingFieldError
here, so cache.read
could return null
in those cases, but I decided against it. Attempting to execute a query with missing fragments seems like a permanent/static error that always needs to be fixed, rather than a behavior influenced by dynamic data (as with missing fields).
72e27a5
to
a67eb81
Compare
This is not a disruptive change for InMemoryCache, since it uses the default implementation of tranformForLink (inherited from ApolloCache), which simply returns the given document. If you're using a different ApolloCache implementation that has a custom transformForLink implementation, this new behavior should be more convenient and flexible, but you should probably double-check that your transformForLink method is suitably cached/idempotent, so multiple calls with the same input document return the same (===) transformed document.
This is just one implementation of a FragmentRegistry that could be provided to InMemoryCache to fill in missing fragment declarations in transformForLink. Learning from past mistakes, we will avoid referring directly to this particular implementation of FragmentRegistry within the InMemoryCache implementation, since that forces the full FragmentRegistry implementation to be bundled even when it is not used.
As long as InMemoryCache uses only this TypeScript interface when handling FragmentRegistry configurations, any implementation can be swapped in without also paying for the bundle size of the default implementation (the FragmentRegistry class).
Since these changes do not include any calls to createFragmentRegistry, the FragmentRegistry class implementation remains optional and thus tree-shakable if unused.
4036a69
to
dc8b8ba
Compare
@benjamn It looks like we could use this to populate fragment bodies at runtime by keeping our reference to the FragmentRegistry and calling We're operating in a Webpack Module Federation environment and are thinking about building a client-only This of course blows up today because the cache writers only have a reference to the original unmodified query, and can't find our fragment defn's at runtime after the query data returns from the link. (Ultimately what we want is some form of first-class client-only directives, but for now it seems like this FragmentRegistry might save us) |
Hey @adamesque! Just to confirm I understand, you are writing your queries something like the following: const QUERY = gql`
query {
user {
...UserFields @remote
}
}
`
// ...
useQuery(QUERY) Then you want your custom If so, I think this would work. Looking through this PR, it looks like the |
That's essentially it, yes! Ideally, we wouldn't have to store the fragment reference in the But based on my reading of the code and a small spike I put together after writing that comment, this seems to work OK for now. I mostly wanted to check because we're not "pre-registering" these in the traditionally understood way as the PR description indicates, but adding them at any point at runtime. Thanks very much for your response! |
You're welcome! Glad to hear you've got something working! I definitely agree that something like the custom directive feature would be much more robust for this kind of thing. Hopefully that's something we can get on our roadmap in the near future! For now, I see no reason why what you're doing is a bad use of the fragment registry, even if it's less ideal. Really cool stuff! |
When a query uses a named fragment, or when fragments use other fragments by name, our current recommendation is to
${...}
-interpolate the necessary fragment declarations into the query'sgql
template string.While this pattern works in small examples, it gets cumbersome when there are multiple (layers of) fragments, and it's all too easy to end up duplicating fragment declarations in the resulting query, if they accidentally get interpolated twice (or more!).
This PR attempts to make this situation substantially easier, by allowing you to pre-register certain named fragments with your
InMemoryCache
, so that you can refer to them by name elsewhere, without needing to interpolate their declarations. That interpolation now happens behind the scenes, each time the query is about to be sent over the network. The dynamic nature of the interpolation guarantees each fragment will be included only once, only if/when it's used.Queries can still declare their own local versions of named fragments, which will take precedence over the pre-registered ones, even if the local fragment is only indirectly referenced by other fragments (see tests for example).
Although fragments and queries still need to be parsed individually at some point, they can be recombined at runtime in a way that requires no re-parsing, so this system should eliminate needless (re)parsing of query documents that directly include associated fragment declarations.
Finally, to prove to myself that this is possible, I structured these changes so the implementation of the
FragmentRegistry
is not automatically included in your JavaScript bundle, unless you choose to importcreateFragmentRegistry
and actually use it. This works because theInMemoryCache
implementation knows only about the abstractFragmentRegistryAPI
(and how to use it), and does not directly import or depend on any of the code implementing that API (theFragmentRegistry
class isn't even exported from the module, for example).