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; @@ -33,13 +37,71 @@ export class DataStore { return this.cache; } - public markQueryResult( + private mergePatch( result: ExecutionResult, + patch: ExecutionPatchResult, + ): void { + if (result) { + let curKeyIndex = 0; + let curKey: string | number; + let curPointer: Record = result as Record; + while (curKeyIndex !== patch.path.length) { + curKey = patch.path[curKeyIndex++]; + const isLeaf = curKeyIndex === patch.path.length; + if (isLeaf) { + if (patch.data) { + // Data may not exist if there is an error in the patch + curPointer[curKey] = patch.data; + } + } else { + if (curPointer[curKey] === undefined) { + // This is indicative of a patch that is not ready to be merged, which + // can happen if patches for inner objects arrive before its parent. + // The graphql execution phase must make sure that this does not + // happen. + throw new Error( + `Failed to merge patch with path '[${patch.path}]'`, + ); + } + if (curPointer[curKey] === null) { + // Check whether it should be an array or an object by looking at the + // next key, then create the object if it is not present. + if (typeof patch.path[curKeyIndex] === 'string') { + curPointer[curKey] = {}; + } else if (typeof patch.path[curKeyIndex] === 'number') { + curPointer[curKey] = []; + } + } + curPointer = curPointer[curKey]; + } + } + } + } + + public markQueryResult( + result: ExecutionResult | ExecutionPatchResult, document: DocumentNode, variables: any, fetchMoreForQueryId: string | undefined, ignoreErrors: boolean = false, ) { + if (isPatch(result)) { + const originalResult: ExecutionResult | null = this.cache.read({ + query: document, + variables: variables, + rootId: 'ROOT_QUERY', + optimistic: false, + returnPartialData: true, + }); + if (originalResult) { + this.mergePatch(originalResult, result); + result = { data: originalResult }; + } else { + // Nothing may be written to cache if the first response had an error + return; + } + } + let writeWithErrors = !graphQLResultHasError(result); if (ignoreErrors && graphQLResultHasError(result) && result.data) { writeWithErrors = true; diff --git a/packages/apollo-client/src/index.ts b/packages/apollo-client/src/index.ts index db5a1e5f25c..4af9ecdc3d2 100644 --- a/packages/apollo-client/src/index.ts +++ b/packages/apollo-client/src/index.ts @@ -23,9 +23,12 @@ export * from './core/types'; export { ApolloError } from './errors/ApolloError'; -import ApolloClient, { ApolloClientOptions } from './ApolloClient'; +import ApolloClient, { + ApolloClientOptions, + DefaultOptions, +} from './ApolloClient'; -export { ApolloClientOptions }; +export { ApolloClientOptions, DefaultOptions }; // export the client as both default and named export { ApolloClient }; diff --git a/packages/apollo-utilities/package.json b/packages/apollo-utilities/package.json index 0e555598f3e..a27b26668b6 100644 --- a/packages/apollo-utilities/package.json +++ b/packages/apollo-utilities/package.json @@ -1,6 +1,6 @@ { "name": "apollo-utilities", - "version": "1.0.17", + "version": "1.0.17-alpha.14", "description": "Utilities for working with GraphQL ASTs", "author": "James Baxley ", "contributors": [ diff --git a/packages/graphql-anywhere/package.json b/packages/graphql-anywhere/package.json index f46393b1767..1a30c89c06f 100644 --- a/packages/graphql-anywhere/package.json +++ b/packages/graphql-anywhere/package.json @@ -1,6 +1,6 @@ { "name": "graphql-anywhere", - "version": "4.1.15", + "version": "4.1.15-alpha.14", "description": "Run GraphQL queries with no schema and just one resolver", "main": "./lib/bundle.umd.js", "module": "./lib/index.js", @@ -39,7 +39,7 @@ ], "license": "MIT", "dependencies": { - "apollo-utilities": "^1.0.17" + "apollo-utilities": "1.0.17-alpha.14" }, "devDependencies": { "@types/graphql": "0.12.7",