@apollo/client
Version:
A fully-featured caching GraphQL client.
563 lines (562 loc) • 24.7 kB
JavaScript
import { isSelectionNode, Kind, visit } from "graphql";
import { cacheSlot } from "@apollo/client/cache";
import { LocalStateError, toErrorLike } from "@apollo/client/errors";
import { stripTypename } from "@apollo/client/utilities";
import { __DEV__ } from "@apollo/client/utilities/environment";
import { argumentsObjectFromField, createFragmentMap, dealias, getFragmentDefinitions, getMainDefinition, hasDirectives, mergeDeep, mergeDeepArray, resultKeyNameFromField, shouldInclude, } from "@apollo/client/utilities/internal";
import { invariant, newInvariantError, } from "@apollo/client/utilities/invariant";
/**
* LocalState enables the use of `@client` fields in GraphQL operations.
*
* `@client` fields are resolved locally using resolver functions rather than
* being sent to the GraphQL server. This allows you to mix local and remote
* data in a single query.
*
* @example
*
* ```ts
* import { LocalState } from "@apollo/client/local-state";
*
* const localState = new LocalState({
* resolvers: {
* Query: {
* isLoggedIn: () => !!localStorage.getItem("token"),
* },
* },
* });
*
* const client = new ApolloClient({
* cache: new InMemoryCache(),
* localState,
* });
* ```
*
* @template TResolvers - The type of resolvers map for type checking
* @template TContext - The type of context value for resolvers
*/
export class LocalState {
context;
resolvers = {};
traverseCache = new WeakMap();
constructor(...[options]) {
this.context = options?.context;
if (options?.resolvers) {
this.addResolvers(options.resolvers);
}
}
/**
* Add resolvers to the local state. New resolvers will be merged with
* existing ones, with new resolvers taking precedence over existing ones
* for the same field.
*
* @param resolvers - The resolvers to add
*
* @example
*
* ```ts
* localState.addResolvers({
* Query: {
* newField: () => "Hello World",
* },
* });
* ```
*/
addResolvers(resolvers) {
this.resolvers = mergeDeep(this.resolvers, resolvers);
}
async execute({ document, client, context, remoteResult, variables = {}, onlyRunForcedResolvers = false, returnPartialData = false, }) {
if (__DEV__) {
invariant(hasDirectives(["client"], document), 47);
validateCacheImplementation(client.cache);
}
// note: if `remoteResult` is `undefined`, we will execute resolvers since
// undefined remote data reflects a client-only query. We specifically want
// to avoid trying to run local resolvers if the server returned `data` as
// `null`.
if (remoteResult?.data === null) {
return remoteResult;
}
const { selectionsToResolve, exportedVariableDefs, operationDefinition, fragmentMap, } = this.collectQueryDetail(document);
const rootValue = remoteResult ? remoteResult.data : {};
const diff = client.cache.diff({
query: toQueryOperation(document),
variables,
returnPartialData: true,
optimistic: false,
});
const requestContext = { ...client.defaultContext, ...context };
const execContext = {
client,
operationDefinition,
fragmentMap,
context: this.context?.({
requestContext,
document,
client,
phase: "resolve",
variables: variables ?? {},
}) ?? requestContext,
variables,
exportedVariables: {},
selectionsToResolve,
onlyRunForcedResolvers,
errors: [],
phase: "resolve",
exportedVariableDefs,
diff,
returnPartialData,
};
const localResult = await this.resolveSelectionSet(operationDefinition.selectionSet, false, rootValue, execContext, []);
const errors = (remoteResult?.errors ?? []).concat(execContext.errors);
const result = {
...remoteResult,
data: mergeDeep(rootValue, localResult),
};
if (errors.length > 0) {
result.errors = errors;
}
return result;
}
async getExportedVariables({ document, client, context, variables, }) {
if (__DEV__) {
invariant(hasDirectives(["client"], document), 48);
validateCacheImplementation(client.cache);
}
const { exportsToResolve, exportedVariableDefs, fragmentMap, operationDefinition, } = this.collectQueryDetail(document);
const diff = client.cache.diff({
query: toQueryOperation(document),
variables,
returnPartialData: true,
optimistic: false,
});
const requestContext = { ...client.defaultContext, ...context };
const execContext = {
client,
operationDefinition,
fragmentMap,
context: this.context?.({
requestContext,
document,
client,
phase: "resolve",
variables: variables ?? {},
}) ?? requestContext,
variables,
exportedVariables: {},
selectionsToResolve: exportsToResolve,
onlyRunForcedResolvers: false,
errors: [],
phase: "exports",
exportedVariableDefs,
diff,
returnPartialData: false,
};
await this.resolveSelectionSet(operationDefinition.selectionSet, false, diff.result, execContext, []);
return stripTypename({
...variables,
...execContext.exportedVariables,
});
}
async resolveSelectionSet(selectionSet, isClientFieldDescendant, rootValue, execContext, path) {
const { client, fragmentMap, variables, operationDefinition } = execContext;
const { cache } = client;
const resultsToMerge = [];
const execute = async (selection) => {
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 (selection.kind === Kind.FIELD) {
const isRootField = selectionSet === operationDefinition.selectionSet;
const isClientField = isClientFieldDescendant ||
(selection.directives?.some((d) => d.name.value === "client") ??
false);
const fieldResult = isClientField ?
await this.resolveClientField(selection, isClientFieldDescendant, rootValue, execContext, selectionSet, path.concat(selection.name.value))
: await this.resolveServerField(selection, rootValue, execContext, path.concat(selection.name.value));
// Don't attempt to merge the client field result if the server result
// was null
if (fieldResult !== undefined && (!isRootField || rootValue !== null)) {
resultsToMerge.push({
[resultKeyNameFromField(selection)]: fieldResult,
});
}
return;
}
if (selection.kind === Kind.INLINE_FRAGMENT &&
selection.typeCondition &&
rootValue?.__typename &&
cache.fragmentMatches(selection, rootValue.__typename)) {
const fragmentResult = await this.resolveSelectionSet(selection.selectionSet, isClientFieldDescendant, rootValue, execContext, path);
if (fragmentResult) {
resultsToMerge.push(fragmentResult);
}
return;
}
if (selection.kind === Kind.FRAGMENT_SPREAD) {
const fragment = fragmentMap[selection.name.value];
invariant(fragment, 49, selection.name.value);
const typename = rootValue?.__typename;
const typeCondition = fragment.typeCondition.name.value;
const matches = typename === typeCondition ||
cache.fragmentMatches(fragment, typename ?? "");
if (matches) {
const fragmentResult = await this.resolveSelectionSet(fragment.selectionSet, isClientFieldDescendant, rootValue, execContext, path);
if (fragmentResult) {
resultsToMerge.push(fragmentResult);
}
}
return;
}
};
await Promise.all(selectionSet.selections.map(execute));
return resultsToMerge.length > 0 ?
mergeDeepArray(resultsToMerge)
: rootValue;
}
resolveServerField(field, rootValue, execContext, path) {
const result = rootValue?.[field.name.value];
if (!field.selectionSet) {
return result;
}
if (result == null) {
if (execContext.phase === "exports") {
for (const [name, def] of Object.entries(execContext.exportedVariableDefs)) {
if (def.ancestors.has(field) && def.required) {
throw new LocalStateError(`${"Field"} '${field.name.value}' is \`${result}\` which contains exported required variable '${name}'. Ensure this value is in the cache or make the variable optional.`, { path });
}
}
}
return result;
}
if (Array.isArray(result)) {
return this.resolveSubSelectedArray(field, false, result, execContext, path);
}
return this.resolveSelectionSet(field.selectionSet, false, result, execContext, path);
}
async resolveClientField(field, isClientFieldDescendant, rootValue, execContext, parentSelectionSet, path) {
const { client, diff, variables, operationDefinition, phase, returnPartialData, onlyRunForcedResolvers, } = execContext;
const isRootField = parentSelectionSet === operationDefinition.selectionSet;
const fieldName = field.name.value;
const typename = isRootField ?
rootValue?.__typename || inferRootTypename(operationDefinition)
: rootValue?.__typename;
const resolverName = `${typename}.${fieldName}`;
function readField() {
const fieldResult = rootValue?.[fieldName];
if (fieldResult !== undefined) {
return fieldResult;
}
return getCacheResultAtPath(diff, path);
}
const defaultResolver = isClientFieldDescendant ? readField
// We expect a resolver to be defined for all `@client` root fields.
// Warn when a resolver is not defined.
: (() => {
const fieldFromCache = getCacheResultAtPath(diff, path);
if (fieldFromCache !== undefined) {
return fieldFromCache;
}
if (!returnPartialData) {
return null;
}
});
const resolver = this.getResolver(typename, fieldName);
let result;
try {
// Avoid running the resolver if we are only trying to run forced
// resolvers. Fallback to read the value from the root field or the cache
// value
if (!onlyRunForcedResolvers || isForcedResolver(field)) {
result =
resolver ?
await Promise.resolve(
// In case the resolve function accesses reactive variables,
// set cacheSlot to the current cache instance.
cacheSlot.withValue(client.cache, resolver, [
rootValue ? dealias(rootValue, parentSelectionSet) : {},
(argumentsObjectFromField(field, variables) ?? {}),
{ requestContext: execContext.context, client, phase },
{ field, fragmentMap: execContext.fragmentMap, path },
]))
: defaultResolver();
}
else {
result = readField();
}
}
catch (e) {
if (phase === "exports") {
for (const [name, def] of Object.entries(execContext.exportedVariableDefs)) {
if (def.ancestors.has(field)) {
throw new LocalStateError(`An error was thrown from resolver '${resolverName}' while resolving ${def.required ? "required" : "optional"} variable '${name}'. Use a try/catch and return \`undefined\` to suppress this error and omit the variable from the request.`, { path, sourceError: e });
}
}
}
this.addError(toErrorLike(e), path, execContext, {
resolver: resolverName,
cause: e,
});
return null;
}
if (phase === "exports") {
field.directives?.forEach((directive) => {
if (directive.name.value !== "export") {
return;
}
const name = getExportedVariableName(directive);
if (!name) {
return;
}
if (result !== undefined) {
execContext.exportedVariables[name] = result;
}
});
if (result == null) {
for (const [name, def] of Object.entries(execContext.exportedVariableDefs)) {
if (def.ancestors.has(field) && def.required) {
throw new LocalStateError(`${resolver ? "Resolver" : "Field"} '${resolverName}' returned \`${result}\` ${def.field === field ? "for" : "which contains exported"} required variable '${name}'.`, { path });
}
}
}
}
if (result === undefined && !returnPartialData) {
if (__DEV__ && phase === "resolve") {
if (resolver && !onlyRunForcedResolvers) {
__DEV__ && invariant.warn(50, resolverName);
}
else if (onlyRunForcedResolvers) {
__DEV__ && invariant.warn(51, resolverName);
}
else {
__DEV__ && invariant.warn(52, fieldName, rootValue);
}
}
result = null;
}
if (result == null || !field.selectionSet) {
return result;
}
if (Array.isArray(result)) {
return this.resolveSubSelectedArray(field, true, result, execContext, path);
}
if (phase === "resolve" && !result.__typename) {
this.addError(newInvariantError(53, result, resolverName), path, execContext, { resolver: resolverName });
return null;
}
return this.resolveSelectionSet(field.selectionSet, true, result, execContext, path);
}
addError(error, path, execContext, meta) {
execContext.errors.push(addExtension(isGraphQLError(error) ?
{ ...error.toJSON(), path }
: { message: error.message, path }, meta));
}
getResolver(typename, fieldName) {
return this.resolvers[typename]?.[fieldName];
}
resolveSubSelectedArray(field, isClientFieldDescendant, result, execContext, path) {
return Promise.all(result.map((item, idx) => {
if (item === null) {
return null;
}
// This is a nested array, recurse.
if (Array.isArray(item)) {
return this.resolveSubSelectedArray(field, isClientFieldDescendant, item, execContext, path.concat(idx));
}
// This is an object, run the selection set on it.
if (field.selectionSet) {
return this.resolveSelectionSet(field.selectionSet, isClientFieldDescendant, item, execContext, path.concat(idx));
}
}));
}
// 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.
collectQueryDetail(document) {
const operationDefinition = getMainDefinition(document);
const fragments = getFragmentDefinitions(document);
const fragmentMap = createFragmentMap(fragments);
const isSingleASTNode = (node) => !Array.isArray(node);
const fields = [];
let rootClientField;
function getCurrentPath() {
return fields.map((field) => field.name.value);
}
const traverse = (definitionNode) => {
if (this.traverseCache.has(definitionNode)) {
return this.traverseCache.get(definitionNode);
}
// Track a separate list of all variable definitions since not all variable
// definitions are used as exports of an `@export` field.
const allVariableDefinitions = {};
const cache = {
exportedVariableDefs: {},
exportsToResolve: new Set(),
selectionsToResolve: new Set(),
};
this.traverseCache.set(definitionNode, cache);
visit(definitionNode, {
VariableDefinition: (definition) => {
allVariableDefinitions[definition.variable.name.value] = {
required: definition.type.kind === Kind.NON_NULL_TYPE,
ancestors: new WeakSet(),
};
},
Field: {
enter(field) {
fields.push(field);
},
leave() {
const removed = fields.pop();
if (removed === rootClientField) {
rootClientField = undefined;
}
},
},
Directive(node, _, __, ___, ancestors) {
const field = fields.at(-1);
if (!field) {
return;
}
if (node.name.value === "export" &&
// Ignore export directives that aren't inside client fields.
// These will get sent to the server
rootClientField) {
const fieldName = field.name.value;
const variableName = getExportedVariableName(node);
if (!variableName) {
throw new LocalStateError(`Cannot determine the variable name from the \`\` directive used on field '${fieldName}'. Perhaps you forgot the \`as\` argument?`, { path: getCurrentPath() });
}
if (!allVariableDefinitions[variableName]) {
throw new LocalStateError(`\`\` directive on field '${fieldName}' cannot export the '$${variableName}' variable as it is missing in the ${operationDefinition.operation} definition.`, { path: getCurrentPath() });
}
cache.exportedVariableDefs[variableName] = {
...allVariableDefinitions[variableName],
field,
};
ancestors.forEach((node) => {
if (isSingleASTNode(node) && isSelectionNode(node)) {
cache.exportsToResolve.add(node);
cache.exportedVariableDefs[variableName].ancestors.add(node);
}
});
}
if (node.name.value === "client") {
rootClientField ??= field;
ancestors.forEach((node) => {
if (isSingleASTNode(node) && isSelectionNode(node)) {
cache.selectionsToResolve.add(node);
}
});
}
},
FragmentSpread(spread, _, __, ___, ancestors) {
const fragment = fragmentMap[spread.name.value];
invariant(fragment, 54, spread.name.value);
const { selectionsToResolve: fragmentSelections } = traverse(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)) {
cache.selectionsToResolve.add(node);
}
});
cache.selectionsToResolve.add(spread);
fragmentSelections.forEach((selection) => {
cache.selectionsToResolve.add(selection);
});
}
},
});
return cache;
};
return {
...traverse(operationDefinition),
operationDefinition,
fragmentMap,
};
}
}
function inferRootTypename({ operation }) {
return operation.charAt(0).toUpperCase() + operation.slice(1);
}
// eslint-disable-next-line @typescript-eslint/no-restricted-types
function isGraphQLError(error) {
return (error.name === "GraphQLError" &&
// Check to see if the error contains keys returned in toJSON. The values
// might be `undefined` if not set, but we don't care about those as we
// can be reasonably sure this is a GraphQLError if all of these properties
// exist on the error
"path" in error &&
"locations" in error &&
"extensions" in error);
}
function addExtension(error, meta) {
return {
...error,
extensions: {
...error.extensions,
localState: meta,
},
};
}
function getExportedVariableName(directive) {
if (directive.arguments) {
for (const arg of directive.arguments) {
if (arg.name.value === "as" && arg.value.kind === Kind.STRING) {
return arg.value.value;
}
}
}
}
function validateCacheImplementation(cache) {
invariant(cache.fragmentMatches, 55);
}
function getCacheResultAtPath(diff, path) {
if (diff.result === null) {
// Intentionally return undefined to signal we have no cache data
return;
}
return path.reduce((value, segment) => value?.[segment], diff.result);
}
function isForcedResolver(field) {
return (field.directives?.some((directive) => {
if (directive.name.value !== "client" || !directive.arguments) {
return false;
}
return directive.arguments.some((arg) => arg.name.value === "always" &&
arg.value.kind === "BooleanValue" &&
arg.value.value === true);
}) ?? false);
}
// If the incoming document is a query, return it as is. Otherwise, build a
// new document containing a query operation based on the selection set
// of the previous main operation.
function toQueryOperation(document) {
const definition = getMainDefinition(document);
const definitionOperation = definition.operation;
if (definitionOperation === "query") {
// Already a query, so return the existing document.
return document;
}
// Build a new query using the selection set of the main operation.
const modifiedDoc = visit(document, {
OperationDefinition: {
enter(node) {
return {
...node,
operation: "query",
};
},
},
});
return modifiedDoc;
}
//# sourceMappingURL=LocalState.js.map