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

Fields without @client directive should not be resolved locally #9573

Merged
merged 13 commits into from
Jan 30, 2023
Merged
5 changes: 5 additions & 0 deletions .changeset/twenty-lemons-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@apollo/client': patch
---

Improve performance of local resolvers by only executing selection sets that contain an `@client` directive. Previously, local resolvers were executed even when the field did not contain `@client`. While the result was properly discarded, the unncessary work could negatively affect query performance, sometimes signficantly.
2 changes: 1 addition & 1 deletion config/bundlesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from "path";
import { gzipSync } from "zlib";
import bytes from "bytes";

const gzipBundleByteLengthLimit = bytes("32.12KB");
const gzipBundleByteLengthLimit = bytes("32.37KB");
const minFile = join("dist", "apollo-client.min.cjs");
const minPath = join(__dirname, "..", minFile);
const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength;
Expand Down
115 changes: 115 additions & 0 deletions src/__tests__/local-state/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,72 @@ describe('Basic resolver capabilities', () => {
});
});

itAsync('should handle @client fields inside fragments', (resolve, reject) => {
jerelmiller marked this conversation as resolved.
Show resolved Hide resolved
const query = gql`
fragment Foo on Foo {
bar
...Foo2
}
fragment Foo2 on Foo {
__typename
baz @client
}
query Mixed {
foo {
...Foo
}
bar {
baz
}
}
`;

const serverQuery = gql`
fragment Foo on Foo {
bar
...Foo2
}
fragment Foo2 on Foo {
__typename
}
query Mixed {
foo {
...Foo
}
bar {
baz
}
}
`;

const resolvers = {
Foo: {
baz: () => false,
},
};

assertWithObserver({
reject,
resolvers,
query,
serverQuery,
serverResult: { data: { foo: { bar: true, __typename: `Foo` }, bar: { baz: true } } },
observer: {
next({ data }) {
try {
expect(data).toEqual({
foo: { bar: true, baz: false, __typename: 'Foo' },
bar: { baz: true },
});
} catch (error) {
reject(error);
}
resolve();
},
},
});
});

itAsync('should have access to query variables when running @client resolvers', (resolve, reject) => {
const query = gql`
query WithVariables($id: ID!) {
Expand Down Expand Up @@ -491,6 +557,55 @@ describe('Basic resolver capabilities', () => {
.then(check),
]);
});

itAsync('should not run resolvers without @client directive (issue #9571)', (resolve, reject) => {
const query = gql`
query Mixed {
foo @client {
bar
}
bar {
baz
}
}
`;

const serverQuery = gql`
query Mixed {
bar {
baz
}
}
`;

const barResolver = jest.fn(() => ({ __typename: `Bar`, baz: false }));

const resolvers = {
Query: {
foo: () => ({ __typename: `Foo`, bar: true }),
bar: barResolver
},
};

assertWithObserver({
reject,
resolvers,
query,
serverQuery,
serverResult: { data: { bar: { baz: true } } },
observer: {
next({ data }) {
try {
expect(data).toEqual({ foo: { bar: true }, bar: { baz: true } });
expect(barResolver).not.toHaveBeenCalled();
} catch (error) {
reject(error);
}
resolve();
},
},
});
});
});

describe('Writing cache data from resolvers', () => {
Expand Down
86 changes: 79 additions & 7 deletions src/core/LocalState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
ASTNode,
visit,
BREAK,
isSelectionNode,
DirectiveNode,
FragmentSpreadNode,
ExecutableDefinitionNode,
} from 'graphql';

import { ApolloCache } from '../cache';
Expand Down Expand Up @@ -62,6 +66,7 @@ export type ExecContext = {
defaultOperationType: string;
exportedVariables: Record<string, any>;
onlyRunForcedResolvers: boolean;
selectionsToResolve: Set<SelectionNode>;
};

export type LocalStateOptions<TCacheShape> = {
Expand All @@ -76,6 +81,7 @@ export class LocalState<TCacheShape> {
private client: ApolloClient<TCacheShape>;
private resolvers?: Resolvers;
private fragmentMatcher: FragmentMatcher;
private selectionsToResolveCache = new WeakMap<ExecutableDefinitionNode, Set<SelectionNode>>()

constructor({
cache,
Expand Down Expand Up @@ -256,12 +262,12 @@ export class LocalState<TCacheShape> {
fragmentMatcher: FragmentMatcher = () => true,
onlyRunForcedResolvers: boolean = false,
) {
const mainDefinition = getMainDefinition(document);
const mainDefinition = getMainDefinition(document) as OperationDefinitionNode;
const fragments = getFragmentDefinitions(document);
const fragmentMap = createFragmentMap(fragments);
const selectionsToResolve = this.collectSelectionsToResolve(mainDefinition, fragmentMap);

const definitionOperation = (mainDefinition as OperationDefinitionNode)
.operation;
const definitionOperation = mainDefinition.operation;

const defaultOperationType = definitionOperation
? definitionOperation.charAt(0).toUpperCase() +
Expand All @@ -280,11 +286,14 @@ export class LocalState<TCacheShape> {
fragmentMatcher,
defaultOperationType,
exportedVariables: {},
selectionsToResolve,
onlyRunForcedResolvers,
};
const isClientFieldDescendant = false;

return this.resolveSelectionSet(
mainDefinition.selectionSet,
isClientFieldDescendant,
rootValue,
execContext,
).then(result => ({
Expand All @@ -295,20 +304,26 @@ export class LocalState<TCacheShape> {

private async resolveSelectionSet<TData>(
selectionSet: SelectionSetNode,
isClientFieldDescendant: boolean,
rootValue: TData,
execContext: ExecContext,
) {
const { fragmentMap, context, variables } = execContext;
const resultsToMerge: TData[] = [rootValue];

const execute = async (selection: SelectionNode): Promise<void> => {
if (!isClientFieldDescendant && !execContext.selectionsToResolve.has(selection)) {
// Skip selections without @client directives
// (still processing if one of the ancestors or one of the child fields has @client directive)
return ;
}
if (!shouldInclude(selection, variables)) {
// Skip this entirely.
return;
}

if (isField(selection)) {
return this.resolveField(selection, rootValue, execContext).then(
return this.resolveField(selection, isClientFieldDescendant, rootValue, execContext).then(
fieldResult => {
if (typeof fieldResult !== 'undefined') {
resultsToMerge.push({
Expand All @@ -334,6 +349,7 @@ export class LocalState<TCacheShape> {
if (execContext.fragmentMatcher(rootValue, typeCondition, context)) {
return this.resolveSelectionSet(
fragment.selectionSet,
isClientFieldDescendant,
rootValue,
execContext,
).then(fragmentResult => {
Expand All @@ -350,6 +366,7 @@ export class LocalState<TCacheShape> {

private async resolveField(
field: FieldNode,
isClientFieldDescendant: boolean,
rootValue: any,
execContext: ExecContext,
): Promise<any> {
Expand Down Expand Up @@ -415,14 +432,17 @@ export class LocalState<TCacheShape> {
return result;
}

const isClientField = field.directives?.some(d => d.name.value === 'client') ?? false

if (Array.isArray(result)) {
return this.resolveSubSelectedArray(field, result, execContext);
return this.resolveSubSelectedArray(field, isClientFieldDescendant || isClientField, result, execContext);
}

// Returned value is an object, and the query has a sub-selection. Recurse.
if (field.selectionSet) {
return this.resolveSelectionSet(
field.selectionSet,
isClientFieldDescendant || isClientField,
result,
execContext,
);
Expand All @@ -432,6 +452,7 @@ export class LocalState<TCacheShape> {

private resolveSubSelectedArray(
field: FieldNode,
isClientFieldDescendant: boolean,
result: any[],
execContext: ExecContext,
): any {
Expand All @@ -443,14 +464,65 @@ export class LocalState<TCacheShape> {

// This is a nested array, recurse.
if (Array.isArray(item)) {
return this.resolveSubSelectedArray(field, item, execContext);
return this.resolveSubSelectedArray(field, isClientFieldDescendant, item, execContext);
}

// This is an object, run the selection set on it.
if (field.selectionSet) {
return this.resolveSelectionSet(field.selectionSet, item, execContext);
return this.resolveSelectionSet(field.selectionSet, isClientFieldDescendant, item, execContext);
}
}),
);
}

// Collect selection nodes on paths from document root down to all @client directives.
// This function takes into account transitive fragment spreads.
// Complexity equals to a single `visit` over the full document.
private collectSelectionsToResolve(
mainDefinition: OperationDefinitionNode,
fragmentMap: FragmentMap
): Set<SelectionNode> {
const isSingleASTNode = (node: ASTNode | readonly ASTNode[]): node is ASTNode => !Array.isArray(node);
const selectionsToResolveCache = this.selectionsToResolveCache;

function collectByDefinition(definitionNode: ExecutableDefinitionNode): Set<SelectionNode> {
if (!selectionsToResolveCache.has(definitionNode)) {
const matches = new Set<SelectionNode>();
selectionsToResolveCache.set(definitionNode, matches);

visit(definitionNode, {
Directive(node: DirectiveNode, _, __, ___, ancestors) {
if (node.name.value === 'client') {
ancestors.forEach(node => {
if (isSingleASTNode(node) && isSelectionNode(node)) {
matches.add(node);
}
})
}
},
FragmentSpread(spread: FragmentSpreadNode, _, __, ___, ancestors) {
const fragment = fragmentMap[spread.name.value];
invariant(fragment, `No fragment named ${spread.name.value}`);

const fragmentSelections = collectByDefinition(fragment);
if (fragmentSelections.size > 0) {
// Fragment for this spread contains @client directive (either directly or transitively)
// Collect selection nodes on paths from the root down to fields with the @client directive
ancestors.forEach(node => {
if (isSingleASTNode(node) && isSelectionNode(node)) {
matches.add(node);
}
})
matches.add(spread);
fragmentSelections.forEach(selection => {
matches.add(selection);
})
}
}
})
}
return selectionsToResolveCache.get(definitionNode)!;
}
return collectByDefinition(mainDefinition);
}
}