diff --git a/CHANGELOG.md b/CHANGELOG.md
index e18e7555ed8..ce747fbfc07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Apollo Client (vNext)
+- Added `@defer` support [#3686](https://github.com/apollographql/apollo-client/pull/3686)
- Adjusted the `graphql` peer dependency to cover explicit minor ranges.
Since the ^ operator only covers any minor version if the major version
is not 0 (since a major version of 0 is technically considered development by
diff --git a/docs/source/features/defer-support.md b/docs/source/features/defer-support.md
index 61537ba0ad4..db844d56a00 100644
--- a/docs/source/features/defer-support.md
+++ b/docs/source/features/defer-support.md
@@ -5,7 +5,7 @@ description: Optimize data loading with the @defer directive
Setting up
-Note: `@defer` support is an experimental feature that is only available in the alpha preview of Apollo Server and Apollo Client.
+> Note: `@defer` support is an **experimental feature** that is only available in alpha versions of Apollo Server and Apollo Client.
- On the server:
@@ -19,7 +19,7 @@ Note: `@defer` support is an experimental feature that is only available in the
```
Or if you are using Apollo Client:
```
- npm install apollo-client@alpha apollo-cache-inmemory@alpha apollo-link-http@alpha apollo-link-error apollo-link
+ npm install apollo-client@alpha apollo-cache-inmemory@alpha apollo-link-http@alpha apollo-link-error apollo-link react-apollo@alpha
```
The `@defer` Directive
@@ -38,15 +38,19 @@ As an example, take a look at the following query that populates a NewsFeed page
query NewsFeed {
newsFeed {
stories {
- text
+ id
+ title
comments {
+ id
text
}
}
recommendedForYou {
story {
- text
+ id
+ title
comments {
+ id
text
}
}
@@ -66,15 +70,19 @@ We can optimize the above query with `@defer`:
query NewsFeed {
newsFeed {
stories {
- text
+ id
+ title
comments @defer {
+ id
text
}
}
recommendedForYou @defer {
story {
- text
+ id
+ title
comments @defer {
+ id
text
}
}
@@ -91,7 +99,7 @@ Once you have added `@defer`, Apollo Server will return an initial response with
{
"data": {
"newsFeed": {
- "stories": [{ "text": "...", "comments": null }],
+ "stories": [{ "id": "...", "title": "...", "comments": null }],
"recommendedForYou": null
}
}
@@ -105,7 +113,9 @@ Once you have added `@defer`, Apollo Server will return an initial response with
"data": [
{
"story": {
- "text": "..."
+ "id": "...",
+ "title": "...",
+ "comments": null
},
"matchScore": 99
}
@@ -176,12 +186,12 @@ You can use it in a React component like this:
```graphql
fragment StoryDetail on Story {
id
- text
+ title
}
query {
newsFeed {
stories {
- text @defer
+ title @defer
...StoryDetail
}
}
@@ -199,8 +209,61 @@ There is no additional setup for the transport required to use `@defer`. By defa
`@defer` is one of those features that work best if used in moderation. If it is used too granularly (on many nested fields), the overhead of performing patching and re-rendering could be worse than just waiting for the full query to resolve. Try to limit `@defer` to fields that take a significantly longer time to load. This is super easy to figure out if you have Apollo Engine set up!
+
Preloading Data with `@defer`
+
+Another super useful pattern for using `@defer` is preloading data that will be required in subsequent views. For illustration, imagine that each story has a `text` field that takes a long time to load. `text` is not required when we load the newsfeed view - we only need it to show the story detail view, which makes a query like this:
+
+```graphql
+query StoryDetail($id: ID!) {
+ story(id: $id) {
+ id
+ title
+ text @defer
+ comments @defer {
+ id
+ text
+ }
+ }
+}
+```
+
+However, instead for waiting for the user to navigate to the story detail view before firing that query, we could add `text` as a deferred field when we first load the newsfeed. This will allow `text` to preload in the background for all the stories.
+
+```graphql
+query NewsFeed {
+ newsFeed {
+ stories {
+ id
+ title
+ text @defer # Not needed now, but preload it first
+ comments @defer {
+ id
+ text
+ }
+ }
+ }
+}
+```
+
+Then, we will need to set up a [cache redirect](https://www.apollographql.com/docs/react/advanced/caching.html#cacheRedirect) to tell Apollo Client where to look for cached data for the `StoryDetail` query.
+
+```javascript
+const client = new ApolloClient({
+ uri: 'http://localhost:4000/graphql',
+ cacheRedirects: {
+ Query: {
+ story: (_, { id }, { getCacheKey }) =>
+ getCacheKey({ __typename: 'Story', id }),
+ },
+ },
+});
+```
+
+Now, when the user navigates to each story detail view, it will load instantly as the data required is already fetched and stored in the cache.
+
+
Use with other GraphQL servers
-If you are sending queries to a GraphQL server that does not support `@defer`, it is likely that the `@defer` directive is simply ignored, or a GraphQL validation error is thrown.
+If you are sending queries to a GraphQL server that does not support `@defer`, it is likely that the `@defer` directive is simply ignored, or a GraphQL validation error is thrown: `Unknown directive "defer"`
-If you would want to implement a GraphQL server that is able to interoperate with Apollo Client, please look at the documentation [here](https://github.com/apollographql/apollo-server/blob/defer-support/docs/source/defer-support.md).
\ No newline at end of file
+To implement a GraphQL server that will interoperate with Apollo Client for `@defer` support, please look at the [specification here](https://github.com/apollographql/apollo-server/blob/defer-support/docs/source/defer-support.md).
diff --git a/package.json b/package.json
index 1d1e73fb47e..73016890003 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,8 @@
"coverage": "lerna run -- coverage",
"coverage:upload": "codecov",
"danger": "danger run --verbose",
- "deploy": "lerna publish -m \"chore: Publish\" --independent && cd packages/apollo-client && npm run deploy"
+ "deploy": "lerna publish -m \"chore: Publish\" --independent && cd packages/apollo-client && npm run deploy",
+ "deploy-alpha": "lerna publish --npm-tag alpha -m \"chore: Publish\" --independent --exact && cd packages/apollo-client && npm run deploy"
},
"bundlesize": [
{
@@ -53,6 +54,7 @@
}
],
"jest": {
+ "testEnvironment": "node",
"transform": {
".(ts|tsx)": "/node_modules/ts-jest/preprocessor.js"
},
diff --git a/packages/apollo-boost/package.json b/packages/apollo-boost/package.json
index 59dce0ebfd4..cc679b4ff3f 100644
--- a/packages/apollo-boost/package.json
+++ b/packages/apollo-boost/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-boost",
- "version": "0.1.12",
+ "version": "0.2.0-alpha.14",
"description": "The easiest way to get started with Apollo Client",
"author": "Peggy Rayzis ",
"contributors": [
@@ -34,19 +34,19 @@
"filesize": "npm run build && npm run build:browser"
},
"dependencies": {
- "apollo-cache": "^1.1.13",
- "apollo-cache-inmemory": "^1.2.6",
- "apollo-client": "^2.3.7",
+ "apollo-cache": "1.1.13-alpha.14",
+ "apollo-cache-inmemory": "1.2.6-alpha.14",
+ "apollo-client": "2.4.0-alpha.14",
"apollo-link": "^1.0.6",
"apollo-link-error": "^1.0.3",
- "apollo-link-http": "^1.3.1",
+ "apollo-link-http": "alpha",
"apollo-link-state": "^0.4.0",
"graphql-tag": "^2.4.2"
},
"devDependencies": {
"@types/graphql": "0.12.7",
"@types/jest": "22.2.3",
- "apollo-utilities": "^1.0.17",
+ "apollo-utilities": "1.0.17-alpha.14",
"browserify": "15.2.0",
"fetch-mock": "6.5.2",
"graphql": "0.13.2",
diff --git a/packages/apollo-cache-inmemory/package.json b/packages/apollo-cache-inmemory/package.json
index 377750e7a24..1931e50ca37 100644
--- a/packages/apollo-cache-inmemory/package.json
+++ b/packages/apollo-cache-inmemory/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-cache-inmemory",
- "version": "1.2.6",
+ "version": "1.2.6-alpha.14",
"description": "Core abstract of Caching layer for Apollo Client",
"author": "James Baxley ",
"contributors": [
@@ -40,9 +40,9 @@
"filesize": "npm run build:browser"
},
"dependencies": {
- "apollo-cache": "^1.1.13",
- "apollo-utilities": "^1.0.17",
- "graphql-anywhere": "^4.1.15"
+ "apollo-cache": "1.1.13-alpha.14",
+ "apollo-utilities": "1.0.17-alpha.14",
+ "graphql-anywhere": "4.1.15-alpha.14"
},
"peerDependencies": {
"graphql": "0.11.7 || ^0.12.0 || ^0.13.0"
diff --git a/packages/apollo-cache-inmemory/src/readFromStore.ts b/packages/apollo-cache-inmemory/src/readFromStore.ts
index 5e3607ed5cf..40fa6a07a22 100644
--- a/packages/apollo-cache-inmemory/src/readFromStore.ts
+++ b/packages/apollo-cache-inmemory/src/readFromStore.ts
@@ -16,11 +16,11 @@ import {
import { Cache } from 'apollo-cache';
import {
- ReadQueryOptions,
IdValueWithPreviousResult,
ReadStoreContext,
DiffQueryAgainstStoreOptions,
StoreObject,
+ ReadQueryOptions,
} from './types';
/**
diff --git a/packages/apollo-cache/package.json b/packages/apollo-cache/package.json
index 2eb54a77fbb..1edcd0c1590 100644
--- a/packages/apollo-cache/package.json
+++ b/packages/apollo-cache/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-cache",
- "version": "1.1.13",
+ "version": "1.1.13-alpha.14",
"description": "Core abstract of Caching layer for Apollo Client",
"author": "James Baxley ",
"contributors": [
@@ -39,7 +39,7 @@
"filesize": "npm run build && npm run build:browser"
},
"dependencies": {
- "apollo-utilities": "^1.0.17"
+ "apollo-utilities": "1.0.17-alpha.14"
},
"devDependencies": {
"@types/graphql": "0.12.7",
diff --git a/packages/apollo-cache/src/cache.ts b/packages/apollo-cache/src/cache.ts
index af71d5df3bc..4650f4ce0bf 100644
--- a/packages/apollo-cache/src/cache.ts
+++ b/packages/apollo-cache/src/cache.ts
@@ -9,9 +9,7 @@ export type Transaction = (c: ApolloCache) => void;
export abstract class ApolloCache implements DataProxy {
// required to implement
// core API
- public abstract read(
- query: Cache.ReadOptions,
- ): T | null;
+ public abstract read(query: Cache.DiffOptions): T | null;
public abstract write(
write: Cache.WriteOptions,
): void;
diff --git a/packages/apollo-client/package.json b/packages/apollo-client/package.json
index 630591b8afc..a7aa597adb3 100644
--- a/packages/apollo-client/package.json
+++ b/packages/apollo-client/package.json
@@ -1,7 +1,7 @@
{
"name": "apollo-client",
"private": true,
- "version": "2.3.7",
+ "version": "2.4.0-alpha.14",
"description": "A simple yet functional GraphQL client.",
"main": "./lib/bundle.umd.js",
"module": "./lib/index.js",
@@ -47,10 +47,10 @@
"license": "MIT",
"dependencies": {
"@types/zen-observable": "^0.8.0",
- "apollo-cache": "^1.1.13",
+ "apollo-cache": "1.1.13-alpha.14",
"apollo-link": "^1.0.0",
- "apollo-link-dedup": "^1.0.0",
- "apollo-utilities": "^1.0.17",
+ "apollo-link-dedup": "alpha",
+ "apollo-utilities": "1.0.17-alpha.14",
"symbol-observable": "^1.0.2",
"zen-observable": "^0.8.0"
},
@@ -65,7 +65,7 @@
"@types/jest": "22.2.3",
"@types/lodash": "4.14.116",
"@types/node": "10.5.6",
- "apollo-cache-inmemory": "^1.2.6",
+ "apollo-cache-inmemory": "1.2.6-alpha.14",
"benchmark": "2.1.4",
"browserify": "15.2.0",
"bundlesize": "0.17.0",
diff --git a/packages/apollo-client/scripts/deploy.sh b/packages/apollo-client/scripts/deploy.sh
index 55775e84250..1bac7cc57d4 100755
--- a/packages/apollo-client/scripts/deploy.sh
+++ b/packages/apollo-client/scripts/deploy.sh
@@ -59,4 +59,4 @@ cp ../../LICENSE npm/
# flow typings
# cp -R flow-typed npm/
-cd npm && npm publish
+cd npm && npm publish --tag alpha
diff --git a/packages/apollo-client/src/core/ObservableQuery.ts b/packages/apollo-client/src/core/ObservableQuery.ts
index ecc2433043f..49b25e90165 100644
--- a/packages/apollo-client/src/core/ObservableQuery.ts
+++ b/packages/apollo-client/src/core/ObservableQuery.ts
@@ -1,10 +1,10 @@
import {
isEqual,
- tryFunctionOrLogError,
maybeDeepFreeze,
+ tryFunctionOrLogError,
} from 'apollo-utilities';
import { GraphQLError } from 'graphql';
-import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus';
+import { isNetworkRequestInFlight, NetworkStatus } from './networkStatus';
import { Observable, Observer, Subscription } from '../util/Observable';
import { QueryScheduler } from '../scheduler/scheduler';
@@ -14,15 +14,16 @@ import { ApolloError } from '../errors/ApolloError';
import { QueryManager } from './QueryManager';
import { ApolloQueryResult, FetchType, OperationVariables } from './types';
import {
- ModifiableWatchQueryOptions,
- WatchQueryOptions,
+ ErrorPolicy,
FetchMoreQueryOptions,
+ ModifiableWatchQueryOptions,
SubscribeToMoreOptions,
- ErrorPolicy,
UpdateQueryFn,
+ WatchQueryOptions,
} from './watchQueryOptions';
import { QueryStoreValue } from '../data/queries';
+import { hasDirectives } from 'apollo-utilities';
export type ApolloCurrentResult = {
data: T | {};
@@ -31,6 +32,17 @@ export type ApolloCurrentResult = {
networkStatus: NetworkStatus;
error?: ApolloError;
partial?: boolean;
+ loadingState?: T | {};
+ // loadingState is exposed to the client for deferred queries, with a shape
+ // that mirrors that of the data, but instead of the leaf nodes being
+ // GraphQLOutputType, it is (undefined | boolean).
+ // Right now, we have not accounted for this difference, but I think it is
+ // still usable in the context of checking for the presence of a field.
+ //
+ // TODO: Additional work needs to be done in `apollo-codegen-core` to generate
+ // a separate type for the loadingState, which will then be passed in as
+ // follows - ApolloCurrentResult
+ // Open issue here: https://github.com/apollographql/apollo-cli/issues/539
};
export interface FetchMoreOptions<
@@ -218,6 +230,17 @@ export class ObservableQuery<
result.errors = queryStoreValue.graphQLErrors;
}
+ if (queryStoreValue) {
+ result.loadingState = queryStoreValue.compactedLoadingState;
+ } else {
+ if (hasDirectives(['defer'], this.options.query)) {
+ // Make sure that we have loadingState for deferred queries
+ // If the queryStore has not been initialized, set loading to true and
+ // wait for the next update.
+ result.loading = true;
+ }
+ }
+
if (!partial) {
const stale = false;
this.lastResult = { ...result, stale };
diff --git a/packages/apollo-client/src/core/QueryManager.ts b/packages/apollo-client/src/core/QueryManager.ts
index 78813670757..9e4041045c1 100644
--- a/packages/apollo-client/src/core/QueryManager.ts
+++ b/packages/apollo-client/src/core/QueryManager.ts
@@ -1,5 +1,12 @@
import { execute, ApolloLink, FetchResult } from 'apollo-link';
-import { ExecutionResult, DocumentNode } from 'graphql';
+import {
+ ExecutionResult,
+ DocumentNode,
+ SelectionNode,
+ FieldNode,
+ Kind,
+ FragmentDefinitionNode,
+} from 'graphql';
import { print } from 'graphql/language/printer';
import { DedupLink as Deduplicator } from 'apollo-link-dedup';
import { Cache } from 'apollo-cache';
@@ -13,6 +20,7 @@ import {
isProduction,
maybeDeepFreeze,
hasDirectives,
+ cloneDeep,
} from 'apollo-utilities';
import { QueryScheduler } from '../scheduler/scheduler';
@@ -38,6 +46,8 @@ import {
ApolloQueryResult,
FetchType,
OperationVariables,
+ ExecutionPatchResult,
+ isPatch,
} from './types';
import { graphQLResultHasError } from 'apollo-utilities';
@@ -386,7 +396,21 @@ export class QueryManager {
!shouldFetch || fetchPolicy === 'cache-and-network';
if (shouldDispatchClientResult) {
- this.queryStore.markQueryResultClient(queryId, !shouldFetch);
+ const query = this.queryStore.get(queryId);
+
+ // Initialize loadingState with the cached results if it is a deferred query
+ let loadingState;
+ if (query.isDeferred) {
+ loadingState = this.initFieldLevelLoadingStates(query.document, {
+ data: storeResult,
+ });
+ }
+
+ this.queryStore.markQueryResultClient(
+ queryId,
+ !shouldFetch,
+ loadingState,
+ );
this.invalidate(true, queryId, fetchMoreForQueryId);
@@ -591,6 +615,7 @@ export class QueryManager {
loading: isNetworkRequestInFlight(queryStoreValue.networkStatus),
networkStatus: queryStoreValue.networkStatus,
stale: true,
+ loadingState: queryStoreValue.loadingState,
};
} else {
resultFromStore = {
@@ -598,6 +623,7 @@ export class QueryManager {
loading: isNetworkRequestInFlight(queryStoreValue.networkStatus),
networkStatus: queryStoreValue.networkStatus,
stale: false,
+ loadingState: queryStoreValue.loadingState,
};
}
@@ -622,7 +648,15 @@ export class QueryManager {
lastResult.data === resultFromStore.data
);
- if (isDifferentResult || previouslyHadError) {
+ if (
+ isDifferentResult ||
+ previouslyHadError ||
+ resultFromStore.loadingState
+ ) {
+ // If loadingState is present, this is a patch from a deferred
+ // query, and we should always treat it as a different result
+ // even though the actual data might be the same (i.e. the patch's
+ // data could be null).
try {
observer.next(maybeDeepFreeze(resultFromStore));
} catch (e) {
@@ -979,23 +1013,43 @@ export class QueryManager {
) {
const { variables, query } = observableQuery.options;
const lastResult = observableQuery.getLastResult();
- const { newData } = this.getQuery(observableQuery.queryId);
+ const { newData, document } = this.getQuery(observableQuery.queryId);
+ const isDeferred =
+ document !== null ? hasDirectives(['defer'], document) : false;
// XXX test this
if (newData) {
return maybeDeepFreeze({ data: newData.result, partial: false });
} else {
- try {
- // the query is brand new, so we read from the store to see if anything is there
- const data = this.dataStore.getCache().read({
+ if (isDeferred) {
+ // For deferred queries, we actually want to use partial data
+ // since certain fields might still be streaming in.
+ // Setting returnPartialData to true so that
+ // an error does not get thrown if fields are missing.
+ const diffResult = this.dataStore.getCache().diff({
query,
variables,
previousResult: lastResult ? lastResult.data : undefined,
optimistic,
+ returnPartialData: true,
});
- return maybeDeepFreeze({ data, partial: false });
- } catch (e) {
- return maybeDeepFreeze({ data: {}, partial: true });
+ return maybeDeepFreeze({
+ data: diffResult.result,
+ partial: !diffResult.complete,
+ });
+ } else {
+ try {
+ // the query is brand new, so we read from the store to see if anything is there
+ const data = this.dataStore.getCache().read({
+ query,
+ variables,
+ previousResult: lastResult ? lastResult.data : undefined,
+ optimistic,
+ });
+ return maybeDeepFreeze({ data, partial: false });
+ } catch (e) {
+ return maybeDeepFreeze({ data: {}, partial: true });
+ }
}
}
}
@@ -1042,6 +1096,18 @@ export class QueryManager {
// See here for more detail: https://github.com/apollostack/apollo-client/issues/231
.filter((x: QueryListener) => !!x)
.forEach((listener: QueryListener) => {
+ if (info.newData) {
+ // Make sure that loadingState is updated for deferred queries
+ const queryStoreValue = this.queryStore.get(id);
+ if (queryStoreValue && queryStoreValue.isDeferred) {
+ this.queryStore.updateLoadingState(
+ id,
+ this.initFieldLevelLoadingStates(queryStoreValue.document, {
+ data: info.newData.result,
+ }),
+ );
+ }
+ }
listener(this.queryStore.get(id), info.newData);
});
});
@@ -1070,6 +1136,47 @@ export class QueryManager {
return observableQueryPromises;
}
+ /**
+ * Given a loadingState tree, update it with the patch by traversing its path
+ */
+ private updateLoadingState(
+ curLoadingState: Record,
+ result: ExecutionPatchResult,
+ ) {
+ const path = result.path;
+ let index = 0;
+ const copy = cloneDeep(curLoadingState);
+ let curPointer = copy;
+ while (index < path.length) {
+ const key = path[index++];
+ if (curPointer && curPointer[key]) {
+ curPointer = curPointer[key];
+ if (index === path.length) {
+ // Reached the leaf node
+ if (Array.isArray(result.data)) {
+ // At the time of instantiating the loadingState from the query AST,
+ // we have no way of telling if a field is an array type. Therefore,
+ // once we receive a patch that has array data, we need to update the
+ // loadingState with an array with the appropriate number of elements.
+
+ const children = cloneDeep(curPointer!._children);
+ const childrenArray = [];
+ for (let i = 0; i < result.data.length; i++) {
+ childrenArray.push(children);
+ }
+ curPointer!._children = childrenArray;
+ }
+ curPointer!._loading = false;
+ break;
+ }
+ if (curPointer && curPointer!._children) {
+ curPointer = curPointer!._children;
+ }
+ }
+ }
+ return copy;
+ }
+
// Takes a request id, query id, a query document and information associated with the query
// and send it to the network interface. Returns
// a promise for the result associated with that request.
@@ -1087,20 +1194,23 @@ export class QueryManager {
fetchMoreForQueryId?: string;
}): Promise {
const { variables, context, errorPolicy = 'none', fetchPolicy } = options;
+ const isDeferred = hasDirectives(['defer'], document);
const operation = this.buildOperationForLink(document, variables, {
...context,
// TODO: Should this be included for all entry points via
// buildOperationForLink?
forceFetch: !this.queryDeduplication,
+ isDeferred,
});
let resultFromStore: any;
let errorsFromStore: any;
+ let curLoadingState: Record;
return new Promise>((resolve, reject) => {
this.addFetchQueryPromise(requestId, resolve, reject);
const subscription = execute(this.deduplicator, operation).subscribe({
- next: (result: ExecutionResult) => {
+ next: (result: ExecutionResult | ExecutionPatchResult) => {
// default the lastRequestId to 1
const { lastRequestId } = this.getQuery(queryId);
if (requestId >= (lastRequestId || 1)) {
@@ -1123,10 +1233,29 @@ export class QueryManager {
}));
}
+ // Initialize a tree of individual loading states for each deferred
+ // field, when the initial response arrives.
+ if (isDeferred && !isPatch(result)) {
+ curLoadingState = this.initFieldLevelLoadingStates(
+ document,
+ result,
+ );
+ }
+
+ if (isDeferred && isPatch(result)) {
+ // Update loadingState for every patch received, by traversing its path
+ curLoadingState = this.updateLoadingState(
+ curLoadingState,
+ result,
+ );
+ }
+
this.queryStore.markQueryResult(
queryId,
result,
fetchMoreForQueryId,
+ isDeferred,
+ curLoadingState,
);
this.invalidate(true, queryId, fetchMoreForQueryId);
@@ -1156,6 +1285,7 @@ export class QueryManager {
variables,
query: document,
optimistic: false,
+ returnPartialData: isDeferred,
});
// this will throw an error if there are missing fields in
// the results which can happen with errors from the server.
@@ -1172,6 +1302,12 @@ export class QueryManager {
reject(error);
},
complete: () => {
+ if (isDeferred) {
+ this.queryStore.markQueryComplete(queryId);
+ this.invalidate(true, queryId, fetchMoreForQueryId);
+ this.broadcastQueries();
+ }
+
this.removeFetchQueryPromise(requestId);
this.setQuery(queryId, ({ subscriptions }) => ({
subscriptions: subscriptions.filter(x => x !== subscription),
@@ -1183,6 +1319,7 @@ export class QueryManager {
loading: false,
networkStatus: NetworkStatus.ready,
stale: false,
+ loadingState: curLoadingState,
});
},
});
@@ -1267,4 +1404,163 @@ export class QueryManager {
},
};
}
+
+ /**
+ * Given a DocumentNode, traverse the tree and initialize loading states for
+ * all fields. Deferred fields are initialized with `_loading` set to true.
+ */
+ private initFieldLevelLoadingStates(
+ doc: DocumentNode,
+ result: ExecutionResult,
+ ) {
+ // Collect all the fragment definitions
+ const fragmentMap: Record = {};
+ doc.definitions
+ .filter(definition => definition.kind === Kind.FRAGMENT_DEFINITION)
+ .forEach(definition => {
+ const fragmentName = (definition as FragmentDefinitionNode).name.value;
+ fragmentMap[
+ fragmentName
+ ] = (definition as FragmentDefinitionNode).selectionSet.selections;
+ });
+
+ const operationDefinition = doc.definitions.filter(
+ definition => definition.kind === Kind.OPERATION_DEFINITION,
+ )[0]; // Take the first element since we do not support multiple operations
+
+ return (this.createLoadingStateTree(
+ operationDefinition as any,
+ fragmentMap,
+ result.data,
+ ) as Record)._children;
+ }
+
+ /**
+ * Recursive function that extracts a tree that mirrors the shape of the query,
+ * adding _loading property to fields which are deferred. Expands
+ * FragmentSpread according to the fragment map that is passed in.
+ * The actual data from the initial response is passed in so that we can
+ * reference the query schema against the data, and handle arrays that we find.
+ * In the case where a partial result is passed in (might be retrieved from
+ * cache), it would set the `_loading` states depending on which fields are
+ * available.
+ */
+ private createLoadingStateTree(
+ selection: SelectionNode,
+ fragmentMap: Record,
+ data: Record | undefined,
+ ): Record | boolean {
+ const hasDeferDirective: boolean = (selection.directives &&
+ selection.directives.length > 0 &&
+ selection.directives.findIndex(directive => {
+ return directive.name.value === 'defer';
+ }) !== -1) as boolean;
+ const isLeaf: boolean =
+ selection.kind !== Kind.INLINE_FRAGMENT &&
+ (!(selection as FieldNode).selectionSet ||
+ (selection as FieldNode).selectionSet!.selections.length === 0);
+
+ const isLoaded = data !== undefined && data !== null;
+ if (isLeaf) {
+ return hasDeferDirective ? { _loading: !isLoaded } : false;
+ }
+
+ const map: { _loading?: boolean; _children?: Record } = {
+ _loading: hasDeferDirective ? !isLoaded : false,
+ _children: undefined,
+ };
+
+ // Extract child selections, replacing FragmentSpreads with its actual selectionSet
+ const selections: SelectionNode[] = [];
+ const expandedFragments: SelectionNode[] = [];
+ (selection as FieldNode).selectionSet!.selections.forEach(
+ childSelection => {
+ if (childSelection.kind === Kind.FRAGMENT_SPREAD) {
+ const fragmentName = childSelection.name.value;
+ fragmentMap[fragmentName].forEach((selection: SelectionNode) => {
+ expandedFragments.push(selection);
+ });
+ } else {
+ selections.push(childSelection);
+ }
+ },
+ );
+
+ // Add expanded FragmentSpreads to the current selection set
+ expandedFragments.forEach(fragSelection => {
+ const fragFieldName = (fragSelection as FieldNode).name.value;
+ const existingSelection = selections.find(
+ selection =>
+ selection.kind !== Kind.INLINE_FRAGMENT &&
+ (selection as FieldNode).name.value === fragFieldName,
+ );
+ if (existingSelection) {
+ const fragSelectionHasDefer =
+ fragSelection.directives &&
+ fragSelection.directives.findIndex(
+ directive => directive.name.value === 'defer',
+ ) >= 0;
+ if (!fragSelectionHasDefer) {
+ // Make sure that the existingSelection is not deferred, since all
+ // selections of the field must specify defer in order for the field
+ // to be deferred. This should match the behavior on apollo-server.
+ if (existingSelection.directives) {
+ existingSelection.directives = existingSelection.directives.filter(
+ directive => directive.name.value !== 'defer',
+ );
+ }
+ }
+ } else {
+ // Add it to the selectionSet
+ selections.push(fragSelection);
+ }
+ });
+
+ // Initialize loadingState recursively for childSelections
+ for (const childSelection of selections) {
+ if (childSelection.kind === Kind.INLINE_FRAGMENT) {
+ const subtree = this.createLoadingStateTree(
+ childSelection,
+ fragmentMap,
+ data,
+ );
+ // Inline fragment node cannot be a leaf node, must have children
+ map._children = Object.assign(
+ map._children || {},
+ (subtree as Record)._children,
+ );
+ } else {
+ const childName = (childSelection as FieldNode).name.value;
+ let childData;
+ let isArray = false;
+ if (data) {
+ childData = data[childName];
+ isArray = Array.isArray(childData);
+ }
+
+ if (!map._children) map._children = {};
+ if (isArray) {
+ // Make sure that the shape of loadingState matches the shape of the
+ // data. If an array is returned for a field, the loadingState should
+ // be initialized with the correct number of elements.
+ const subtreeArr = childData.map((d: any) => {
+ const subtree = this.createLoadingStateTree(
+ childSelection,
+ fragmentMap,
+ d,
+ );
+ return typeof subtree === 'boolean' ? subtree : subtree._children;
+ });
+ map._children[childName] = { _children: subtreeArr };
+ } else {
+ map._children[childName] = this.createLoadingStateTree(
+ childSelection,
+ fragmentMap,
+ childData,
+ );
+ }
+ }
+ }
+ return map;
+ }
}
diff --git a/packages/apollo-client/src/core/networkStatus.ts b/packages/apollo-client/src/core/networkStatus.ts
index c49fab9882c..83a9cec0ae3 100644
--- a/packages/apollo-client/src/core/networkStatus.ts
+++ b/packages/apollo-client/src/core/networkStatus.ts
@@ -43,6 +43,12 @@ export enum NetworkStatus {
* No request is in flight for this query, but one or more errors were detected.
*/
error = 8,
+
+ /**
+ * Only a partial response has been received, this could come from the usage
+ * of @defer/live/stream directives.
+ */
+ partial = 9,
}
/**
diff --git a/packages/apollo-client/src/core/types.ts b/packages/apollo-client/src/core/types.ts
index 2d44d8b5a5f..eb366d4f840 100644
--- a/packages/apollo-client/src/core/types.ts
+++ b/packages/apollo-client/src/core/types.ts
@@ -1,4 +1,4 @@
-import { DocumentNode, GraphQLError } from 'graphql';
+import { DocumentNode, ExecutionResult, GraphQLError } from 'graphql';
import { QueryStoreValue } from '../data/queries';
import { NetworkStatus } from './networkStatus';
import { FetchResult } from 'apollo-link';
@@ -21,6 +21,7 @@ export type ApolloQueryResult = {
loading: boolean;
networkStatus: NetworkStatus;
stale: boolean;
+ loadingState?: Record;
};
export enum FetchType {
@@ -42,3 +43,19 @@ export type MutationQueryReducer = (
export type MutationQueryReducersMap = {
[queryName: string]: MutationQueryReducer;
};
+
+/**
+ * Define a new type for patches that are sent as a result of using defer.
+ * It is basically the same as ExecutionResult, except that it has a "path"
+ * field that keeps track of the where the patch is to be merged with the
+ * original result.
+ */
+export interface ExecutionPatchResult extends ExecutionResult {
+ path: (string | number)[];
+}
+
+export function isPatch(
+ data: ExecutionResult | ExecutionPatchResult,
+): data is ExecutionPatchResult {
+ return (data as ExecutionPatchResult).path !== undefined;
+}
diff --git a/packages/apollo-client/src/data/queries.ts b/packages/apollo-client/src/data/queries.ts
index 35264114f8d..a52ee2e3af9 100644
--- a/packages/apollo-client/src/data/queries.ts
+++ b/packages/apollo-client/src/data/queries.ts
@@ -1,16 +1,21 @@
-import { DocumentNode, GraphQLError, ExecutionResult } from 'graphql';
+import { DocumentNode, ExecutionResult, GraphQLError } from 'graphql';
import { print } from 'graphql/language/printer';
import { isEqual } from 'apollo-utilities';
import { NetworkStatus } from '../core/networkStatus';
+import { ExecutionPatchResult, isPatch } from '../core/types';
+import { hasDirectives } from 'apollo-utilities';
export type QueryStoreValue = {
document: DocumentNode;
+ isDeferred: boolean;
variables: Object;
previousVariables?: Object | null;
networkStatus: NetworkStatus;
networkError?: Error | null;
graphQLErrors?: GraphQLError[];
+ loadingState?: Record;
+ compactedLoadingState?: Record;
metadata: any;
};
@@ -88,6 +93,9 @@ export class QueryStore {
// before the initial fetch is done, you'll get an error.
this.store[query.queryId] = {
document: query.document,
+ isDeferred: query.document.definitions
+ ? hasDirectives(['defer'], query.document)
+ : false,
variables: query.variables,
previousVariables,
networkError: null,
@@ -112,18 +120,60 @@ export class QueryStore {
}
}
+ public markQueryComplete(queryId: string) {
+ if (!this.store[queryId]) return;
+ this.store[queryId].networkStatus = NetworkStatus.ready;
+ }
+
+ public updateLoadingState(
+ queryId: string,
+ loadingState: Record,
+ ) {
+ if (!this.store[queryId]) return;
+ this.store[queryId].loadingState = loadingState;
+ this.store[queryId].compactedLoadingState = this.compactLoadingStateTree(
+ loadingState,
+ );
+ }
+
public markQueryResult(
queryId: string,
- result: ExecutionResult,
+ result: ExecutionResult | ExecutionPatchResult,
fetchMoreForQueryId: string | undefined,
+ isDeferred: boolean,
+ loadingState?: Record,
) {
if (!this.store[queryId]) return;
+ // Store loadingState along with a compacted version of it
+ if (isDeferred && loadingState) {
+ this.store[queryId].loadingState = loadingState;
+ this.store[queryId].compactedLoadingState = this.compactLoadingStateTree(
+ loadingState,
+ );
+ }
+
+ if (isDeferred && isPatch(result)) {
+ // Merge graphqlErrors from patch, if any
+ if (result.errors) {
+ const errors: GraphQLError[] = [];
+ this.store[queryId].graphQLErrors!.forEach(error => {
+ errors.push(error);
+ });
+ result.errors.forEach(error => {
+ errors.push(error);
+ });
+ this.store[queryId].graphQLErrors = errors;
+ }
+ }
+
this.store[queryId].networkError = null;
this.store[queryId].graphQLErrors =
result.errors && result.errors.length ? result.errors : [];
this.store[queryId].previousVariables = null;
- this.store[queryId].networkStatus = NetworkStatus.ready;
+ this.store[queryId].networkStatus = isDeferred
+ ? NetworkStatus.partial
+ : NetworkStatus.ready;
// If we have a `fetchMoreForQueryId` then we need to update the network
// status for that query. See the branch for query initialization for more
@@ -154,9 +204,21 @@ export class QueryStore {
}
}
- public markQueryResultClient(queryId: string, complete: boolean) {
+ public markQueryResultClient(
+ queryId: string,
+ complete: boolean,
+ loadingState?: Record,
+ ) {
if (!this.store[queryId]) return;
+ // Store loadingState if it is passed in
+ if (loadingState) {
+ this.store[queryId].loadingState = loadingState;
+ this.store[queryId].compactedLoadingState = this.compactLoadingStateTree(
+ loadingState,
+ );
+ }
+
this.store[queryId].networkError = null;
this.store[queryId].previousVariables = null;
this.store[queryId].networkStatus = complete
@@ -187,4 +249,37 @@ export class QueryStore {
{} as { [queryId: string]: QueryStoreValue },
);
}
+
+ /**
+ * Given a loadingState tree, it returns a compacted version of it that
+ * reduces the amount of boilerplate code required to access nested fields.
+ * The structure of this will mirror the response data, with deferred fields
+ * set to undefined until its patch is received.
+ */
+ private compactLoadingStateTree(
+ loadingState?: Record,
+ ): Record | undefined {
+ if (!loadingState) return loadingState;
+ const state: Record = {};
+
+ for (let key in loadingState) {
+ const o = loadingState[key];
+ if (o._loading) {
+ continue;
+ }
+ if (o._children) {
+ if (Array.isArray(o._children)) {
+ state[key] = o._children.map((c: any) =>
+ this.compactLoadingStateTree(c),
+ );
+ } else {
+ state[key] = this.compactLoadingStateTree(o._children);
+ }
+ continue;
+ }
+ state[key] = true;
+ }
+
+ return state;
+ }
}
diff --git a/packages/apollo-client/src/data/store.ts b/packages/apollo-client/src/data/store.ts
index 05ad5ef9bec..243c9d3f4f7 100644
--- a/packages/apollo-client/src/data/store.ts
+++ b/packages/apollo-client/src/data/store.ts
@@ -7,7 +7,11 @@ import {
tryFunctionOrLogError,
graphQLResultHasError,
} from 'apollo-utilities';
-import { MutationQueryReducer } from '../core/types';
+import {
+ ExecutionPatchResult,
+ isPatch,
+ MutationQueryReducer,
+} from '../core/types';
export type QueryWithUpdater = {
updater: MutationQueryReducer