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

Refactor processSelectionSet field flattening to allow @client and @defer directives on fragment spreads #8951

Merged
merged 15 commits into from
Oct 22, 2021

Conversation

benjamn
Copy link
Member

@benjamn benjamn commented Oct 20, 2021

A query selection set may contain a mixture of fields and fragment spreads (either inline or named), but once a concrete result object with a __typename is available, the fragments can be flattened into their constituent fields (depending on whether each fragment matches the given __typename), following the CollectFields pseudocode algorithm.

Previously, this flattening was intermingled with the processing of result data in StoreWriter#processSelectionSet, but the flattening can be performed in its entirety before calling processFieldValue for any fields, which makes the code easier to reason about, and possibly a bit faster. This PR introduces a new StoreWriter helper method, flattenFields, which performs that field flattening, closely following CollectFields.

A side-goal of this work was to fix a long-standing issue where @client directives on fragment spreads were ignored, because the fragment spread SelectionNode that has the relevant selection.directives information was removed by the previous flattening algorithm. Unfortunately, this means flattenFields potentially needs to provide different WriteContext objects for different fields, depending on whether the field's path has a @client directive. This new functionality requires a slight deviation from the visitedFragments logic in CollectFields, because a fragment may now be revisited if encountered more than once with different @client configurations. Fortunately, the revisitation logic can be confined within flattenFields, so it does not need to clutter processSelectionSet.

As a bonus, my effort to reimplement @client handling extends naturally to @defer (see f8ccf8d), since @defer is also a directive that "cascades" (applies to the whole subtree). Initially, this allows us to avoid warning about missing fields that are deferred, the same way we avoid warning about missing @client fields, but it will no doubt have more use when we fully implement @defer support (see #8184).

Comment on lines 2969 to 3005
check(gql`
query Q {
...FragAB @client
...FragB
rootField
}

fragment FragAB on Query {
aField
...FragB
}

fragment FragB on Query {
bField
}
`, {
aField: true,
bField: false,
rootField: false,
});
Copy link
Member Author

Choose a reason for hiding this comment

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

Here's a good example of a field (bField) that's visited more than once by the flattening algorithm, since ...FragB is used twice, but the two different paths have different @client configurations, thanks to ...FragAB @client, so the non-@client one "wins" (because the bField cannot be entirely skipped on the server).

selection.directives &&
selection.directives.some(d => d.name.value === "client")
);
).forEach((context, field) => {
Copy link
Member Author

Choose a reason for hiding this comment

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

This context parameter shadows the one passed into processSelectionSet, though they will be === in many cases (for example, when @client and/or @defer are not present in the query).

Comment on lines +357 to +381
} else if (
__DEV__ &&
!context.clientOnly &&
!context.deferred &&
Copy link
Member Author

Choose a reason for hiding this comment

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

Here's the one place where we currently consume context.deferred.

Comment on lines +502 to +531
// If this field has been visited along another recursive path
// before, the final context should have clientOnly or deferred set
// to true only if *all* paths have the directive (hence the &&).
clientOnly = clientOnly && existing.clientOnly;
deferred = deferred && existing.deferred;
Copy link
Member Author

@benjamn benjamn Oct 20, 2021

Choose a reason for hiding this comment

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

Here's where we ultimately determine if any of the paths to a given field do not have @client, because that means the field should not be considered client-only.

Similar logic applies to @defer: if the field shows up in any non-deferred path through the query, the field should be expected in the initial result, rather than arriving later.

Comment on lines 56 to 57
// Directive metadata for @client and @defer. We could use a bitfield for this
// information to save some space, if that matters.
clientOnly: boolean;
deferred: boolean;
Copy link
Member Author

Choose a reason for hiding this comment

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

As a counterpoint to the bitfield idea, we may eventually want these value types to provide more detailed metadata about the @client or @defer directive(s) that are currently active, rather than just a boolean, but that's all we need for now.

Copy link
Contributor

@brainkim brainkim left a comment

Choose a reason for hiding this comment

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

Some thoughts! The only necessary change is to handle if arguments for defer (or I can probably do it too).

src/cache/inmemory/writeToStore.ts Show resolved Hide resolved
src/cache/inmemory/writeToStore.ts Outdated Show resolved Hide resolved
src/cache/inmemory/writeToStore.ts Outdated Show resolved Hide resolved
src/cache/inmemory/writeToStore.ts Outdated Show resolved Hide resolved
src/cache/inmemory/writeToStore.ts Outdated Show resolved Hide resolved
getFragmentFromSelection(inlineOrSpread, context.fragmentMap);
if (inlineOrDefinition &&
policies.fragmentMatches(
inlineOrDefinition, typename, result, context.variables)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it a problem that typename might not match for interface/union fragments?

Copy link
Member Author

Choose a reason for hiding this comment

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

The policies.fragmentMatches method knows all about that, thanks to possibleTypes.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes but it’s okay that typename is for the original selectionSet and not the typename from the fragment? I’m probably just being dumb about this.

src/cache/inmemory/writeToStore.ts Show resolved Hide resolved
@brainkim brainkim self-requested a review October 21, 2021 19:01
Copy link
Contributor

@brainkim brainkim left a comment

Choose a reason for hiding this comment

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

💜

@benjamn benjamn force-pushed the client-directive-on-fragment-spreads branch from 128722e to 8f5708a Compare October 22, 2021 17:12
@benjamn benjamn merged commit 5d85f65 into release-3.5 Oct 22, 2021
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 15, 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.

2 participants