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

Allow registering named fragments with InMemoryCache to support using ...FragmentName in queries without redeclaring FragmentName in every query #9764

Merged
merged 10 commits into from
Sep 21, 2022

Conversation

benjamn
Copy link
Member

@benjamn benjamn commented May 26, 2022

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's gql 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 import createFragmentRegistry and actually use it. This works because the InMemoryCache implementation knows only about the abstract FragmentRegistryAPI (and how to use it), and does not directly import or depend on any of the code implementing that API (the FragmentRegistry class isn't even exported from the module, for example).

@benjamn benjamn added this to the Release 3.7 milestone May 26, 2022
@benjamn benjamn self-assigned this May 26, 2022
@benjamn benjamn requested a review from hwillson May 26, 2022 19:48
@benjamn benjamn changed the title Allow registering named fragments with InMemoryCache to support ...FragmentName in queries without redeclaring FragmentName in the query Allow registering named fragments with InMemoryCache to support using ...FragmentName in queries without redeclaring FragmentName in every query May 26, 2022
Comment on lines +1044 to +1055
// 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,
Copy link
Member Author

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 of transformForLink (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.

@jpvajda
Copy link
Contributor

jpvajda commented May 26, 2022

This is cool, and I'm curious if we need to update some documentation on the ability to register named fragments with InMemoryCache and if so is that just a new section in https://github.com/apollographql/apollo-client/blob/main/docs/source/data/fragments.md

@benjamn
Copy link
Member Author

benjamn commented May 26, 2022

@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.

Comment on lines +54 to +57
definitions.forEach((node, name) => {
if (node !== this.registry[name]) {
this.registry[name] = node;
this.invalidate(name);
Copy link
Member Author

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.

@jpvajda
Copy link
Contributor

jpvajda commented May 26, 2022

@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.

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.

This is really awesome @benjamn! 🎉

});
});

it("can register fragments with unbound ...spreads", () => {
Copy link
Member

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 {
Copy link
Member

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"?
Copy link
Member

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.

Copy link
Member

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?
Copy link
Member

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.

@benjamn benjamn force-pushed the named-fragment-registry branch from f14369e to 1d91325 Compare June 28, 2022 15:42
@benjamn benjamn force-pushed the named-fragment-registry branch from 1d91325 to 3ffb5eb Compare September 12, 2022 21:03
Comment on lines 183 to 189
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;
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 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).

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.
@benjamn benjamn force-pushed the named-fragment-registry branch from 4036a69 to dc8b8ba Compare September 21, 2022 16:58
@benjamn benjamn merged commit 05e45c9 into release-3.7 Sep 21, 2022
@benjamn benjamn deleted the named-fragment-registry branch September 21, 2022 17:04
@adamesque
Copy link

@benjamn It looks like we could use this to populate fragment bodies at runtime by keeping our reference to the FragmentRegistry and calling register as needed to add new fragments. Does that sound accurate?

We're operating in a Webpack Module Federation environment and are thinking about building a client-only @remote directive to allow queries to reference remote fragments — we'd use an Apollo Link to discover and retrieve the fragment bodies at runtime and remove the directive before sending the query over the wire.

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)

@jerelmiller
Copy link
Member

jerelmiller commented Nov 28, 2022

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 ApolloLink to be able to look for those @remote directives, look up the definition from a remote source, store the fragment definition in the FragmentRegistry, then send the full query, including the fragment definition, to your server. Does this sound accurate?

If so, I think this would work. Looking through this PR, it looks like the writeToStore module looks up fragments before writing to the cache, so storing the fragment in the FragmentRegistry before a cache write should theoretically work.

@adamesque
Copy link

That's essentially it, yes! Ideally, we wouldn't have to store the fragment reference in the FragmentRegistry — the only reason to do that now is because we can't completely safely modify operation ASTs in a link for the reasons I mentioned. Because pushing into the registry makes a fragment universally available to other operations, I can see valid reasons why (for this feature) we might prefer not to have to use the registry in this way.

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!

@jerelmiller
Copy link
Member

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!

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 14, 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.

6 participants